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/.commitlintrc.json b/.commitlintrc.json deleted file mode 100644 index bb44fc44..00000000 --- a/.commitlintrc.json +++ /dev/null @@ -1 +0,0 @@ -{ "extends": "@binden/commitlint-config" } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..7698c2a6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# The settings of Prettier (which reads this file too), for the other editors +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +# Two trailing spaces may be a line break +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 30e4cd53..00000000 --- a/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -# Typescript compiled files -dist/ - -# Documentation -docs/ 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/.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/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 2cf2dfe2..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] -- `binden` version [e.g. 1.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/dependabot.yml b/.github/dependabot.yml index 9367d5c0..9da51927 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,25 @@ updates: interval: daily allow: - dependency-type: direct + # 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 @@ -21,6 +40,10 @@ updates: directory: / schedule: interval: daily + groups: + actions: + patterns: + - "*" assignees: - vansergen labels: 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. diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 00000000..736759f1 --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,6 @@ +# Secret scanning (https://docs.github.com/en/code-security/secret-scanning): +# the private key of the TLS tests of binden is a throwaway key, generated for +# them and published on purpose. Only that file is excluded; no tarball contains +# it (scripts/pack-check.mjs refuses keys and tests). +paths-ignore: + - "packages/binden/test/cert/key.pem" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0630c03a..cd04f617 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,110 +1,591 @@ name: CI -on: push +on: + push: + branches: [main] + pull_request: + # Recovers a release that failed after its tag was pushed (see "Releases" in the README): + # from `main` only (dispatched from another branch, the recovery jobs are skipped) + workflow_dispatch: + inputs: + version: + description: The version of the release to recover (its tag v exists) + required: true + type: string + +permissions: + contents: read + +# Outdated pull request runs are cancelled. Runs on `main` are never cancelled once they run, +# so a release cannot be interrupted half-way (tag pushed, packages partially published). A +# waiting run is cancelled by the next one of its group (GitHub keeps one pending run per +# group): a dispatched recovery may be, see "Recovering a release" in the README. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - test: + lint: + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20, 22] + timeout-minutes: 20 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: ${{ matrix.node-version }} + node-version: 24 check-latest: true + cache: npm - - name: Install dependencies + - 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 + - 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: commitlint - run: npm run commitlint - - - name: Tests - run: npm test + - name: Tooling tests (release scripts, test runner) + run: npm run test:tooling - - name: Publish (test) - run: npm publish --dry-run + # 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 - name: Docs run: npm run docs:build - - name: Coverage + - name: Upload docs + if: github.event_name == 'push' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: docs + + # The archives of the next release, made without any right to publish: the next version + # (semantic-release in a local clone, see scripts/release-rehearsal.mjs), `verify`, + # `version` and `pack` of every workspace on the runtime of the release, the checks of the + # archives, then the archives and their manifest as an artifact of this run. The `release` + # job publishes exactly these archives (see "Releases" in the README); in pull requests, + # this job is the rehearsal of the release. + package: + if: github.event_name != 'workflow_dispatch' + + runs-on: ubuntu-latest + + timeout-minutes: 30 + + permissions: + contents: read + + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + manifest-digest: ${{ steps.digest.outputs.sha256 }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # The runtime of the `release` and `recover` jobs, so that the archives that they + # publish are the ones that npm would pack there + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.16.0 + + - 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 + + - name: Signatures of the dependencies + run: npm audit signatures + + # The next version and its release notes (or the current version when no release is + # due), then the archives of the release and their manifest (scripts/release.mjs + # `verify`, `version` and `pack`), checked in another clone as the release job checks + # them (`version` and `publish --dry-run`): nothing is tagged or published + - name: 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 release, TypeScript and ESM) + run: node scripts/consumer-check.mjs --tarballs "$RUNNER_TEMP/release" + + # Reported as an output of this job, which the publishing job compares with the + # manifest of the artifact that it downloads + - name: Digest of the manifest + id: digest + run: | + digest=$(sha256sum "$RUNNER_TEMP/release/release-manifest.json" | cut -d ' ' -f 1) + echo "sha256=$digest" >> "$GITHUB_OUTPUT" + + - name: Upload the archives + id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-archives + path: | + ${{ runner.temp }}/release/release-manifest.json + ${{ runner.temp }}/release/*.tgz + if-no-files-found: error + + test: + if: github.event_name != 'workflow_dispatch' + + runs-on: ubuntu-latest + + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + # 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 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + 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 - uses: coverallsapp/github-action@master + if: matrix.node-version == 24 + uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: coverage/lcov.info + + # 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 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - github-token: ${{ secrets.github_token }} - flag-name: Node.js v${{ matrix.node-version }} - parallel: true + 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 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 - coverage: - needs: [test] + - 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: - - name: Coveralls Finished - uses: coverallsapp/github-action@master + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - github-token: ${{ secrets.github_token }} - parallel-finished: true + persist-credentials: false + + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 release: - if: ${{ github.ref == 'refs/heads/main' }} + if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [test, coverage] + needs: [lint, test, windows, macos, package] runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 + 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 - - uses: actions/setup-node@v7 + permissions: + contents: write + issues: write + pull-requests: write + id-token: write + + env: + HUSKY: 0 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - node-version: 22 - check-latest: true + fetch-depth: 0 - - name: Import GPG key - run: echo $GPG_KEY | base64 --decode | gpg --batch --import - env: - GPG_KEY: ${{ secrets.GPG_KEY }} + # The runtime of the `package` job, which made the archives: its npm publishes them + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.16.0 + registry-url: https://registry.npmjs.org - - name: Add the custom gpg executable + - name: Runtime (Node.js 24.16.0 and its npm 11.13.0) run: | - rm -rf /tmp/gpg.sh - echo 'gpg --batch --pinentry-mode=loopback --passphrase $GPG_KEY_PASSPHRASE $@' >> /tmp/gpg.sh - chmod +x /tmp/gpg.sh + node --version + npm --version + test "$(node --version)" = v24.16.0 + test "$(npm --version)" = 11.13.0 + + # semantic-release and its plugins only: nothing is built or packed in this job, and + # the scripts of the dependencies do not run with its rights + - name: Install the release tooling + run: npm ci --ignore-scripts - - name: Setup git + - name: Signatures of the dependencies + run: npm audit signatures + + # The artifact that the `package` job of this run uploaded (by its id, never the + # artifact of another run), whose digest the action checks + - name: Download the archives of the package job + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.package.outputs.artifact-id }} + path: ${{ runner.temp }}/release + + - name: Check the manifest of the archives run: | - git config commit.gpgsign true - git config user.signingkey $GPG_KEY_ID - git config gpg.program /tmp/gpg.sh + digest=$(sha256sum "$RUNNER_TEMP/release/release-manifest.json" | cut -d ' ' -f 1) + if [ "$digest" != "$MANIFEST_DIGEST" ]; then + echo "::error::The manifest of the archives ($digest) is not the one that the packing job reported (${MANIFEST_DIGEST:-none})" + exit 1 + fi env: - GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} - - - name: Install dependencies - run: npm ci + MANIFEST_DIGEST: ${{ needs.package.outputs.manifest-digest }} + # Tags the release, publishes the archives of the `package` job and creates the GitHub + # Release, whose notes are the changelog. Before the tag, `prepareCmd` sets the version of + # the release in the workspaces (scripts/release.mjs `version`, no build and no pack) + # and checks the archives with `publish --dry-run`: their version must be the next one, + # their commit HEAD, their integrity the one of the manifest, their files the expected + # ones, and none may be published yet. Any difference fails the release before anything + # is tagged. `publishCmd` then publishes these archives once the registry is checked for + # all of them. 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 }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + RELEASE_DIR: ${{ runner.temp }}/release + + # The archives of a release to recover (see "Recovering a release" in the README), made + # without any right to publish: once the version, its tag and the commit of the tag are + # checked, the commit of the tag is packed on the runtime of the release, and the archives + # are checked and uploaded as an artifact of this run for the `recover` job. From `main` only: + # a run dispatched from another branch runs the workflow of that branch (which may not have + # this condition), whose `recover` job the `release` environment refuses (it admits `main` + # only, see "Repository settings" in the README) + recover-package: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + + runs-on: ubuntu-latest + + timeout-minutes: 30 + + permissions: + contents: read + + env: + VERSION: ${{ inputs.version }} + + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + manifest-digest: ${{ steps.digest.outputs.sha256 }} + + 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: + 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" + + # The runtime of the `release` job, so that the archives are the ones that it packed + # (bit for bit) from the same commit + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24.16.0 + + - 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 + + - name: Signatures of the dependencies + run: npm audit signatures + + - name: Pack the archives of the release + run: | + node scripts/release.mjs version "$VERSION" + node scripts/release.mjs pack "$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 release, TypeScript and ESM) + run: node scripts/consumer-check.mjs --tarballs "$RUNNER_TEMP/release" + + - name: Digest of the manifest + id: digest + run: | + digest=$(sha256sum "$RUNNER_TEMP/release/release-manifest.json" | cut -d ' ' -f 1) + echo "sha256=$digest" >> "$GITHUB_OUTPUT" + + - name: Upload the archives + id: upload + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-archives + path: | + ${{ runner.temp }}/release/release-manifest.json + ${{ runner.temp }}/release/*.tgz + if-no-files-found: error + + # Publishes the packages of a release that are not published yet, from the archives of the + # `recover-package` job of this run (`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), from `main`. + recover: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + + needs: recover-package + + runs-on: ubuntu-latest + + timeout-minutes: 30 + + environment: release + + concurrency: + group: publish + cancel-in-progress: false + + permissions: + contents: write + id-token: write + + env: + VERSION: ${{ inputs.version }} + + steps: + # The same checks as the `recover-package` job, before anything of the repository runs + - 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: + fetch-depth: 0 + persist-credentials: false + + - 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.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: Download the archives of the recover-package job + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.recover-package.outputs.artifact-id }} + path: ${{ runner.temp }}/release + + - name: Check the manifest of the archives + run: | + digest=$(sha256sum "$RUNNER_TEMP/release/release-manifest.json" | cut -d ' ' -f 1) + if [ "$digest" != "$MANIFEST_DIGEST" ]; then + echo "::error::The manifest of the archives ($digest) is not the one that the packing job reported (${MANIFEST_DIGEST:-none})" + exit 1 + fi + env: + MANIFEST_DIGEST: ${{ needs.recover-package.outputs.manifest-digest }} + + # Sets the version in the workspaces (no build, no pack: nothing of the dependencies is + # installed in this job), checks the archives against the commit of the tag and 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 "$RUNNER_TEMP/release" --skip-existing + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + + # 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: | + 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 }} + + pages: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + # 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, macos] + + runs-on: ubuntu-latest + + timeout-minutes: 10 + + 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@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 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/.gitignore b/.gitignore index 67045665..e577d705 100644 --- a/.gitignore +++ b/.gitignore @@ -1,104 +1,31 @@ -# 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 +# Dependencies node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ -# TypeScript cache +# Build output +dist/ *.tsbuildinfo -# Optional npm cache directory -.npm +# Coverage (c8) +coverage/ -# Optional eslint cache -.eslintcache +# API documentation (TypeDoc), deployed by CI +docs/ -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' +# 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/ +# Logs +*.log -# FuseBox cache -.fusebox/ +# OS files +.DS_Store -# DynamoDB Local files -.dynamodb/ +# Environment files (may contain secrets) +.env +.env.* +!.env.example -# TernJS port file -.tern-port +# 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/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec1..00000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100755 new mode 100644 index a78cc751..da994831 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -npx commitlint --edit $1 +npx --no -- commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 index 2312dc58..1524ad68 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npx lint-staged +npx --no -- lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 8cd68c57..e0b2b2c9 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ { - "*.{js,cjs,mjs,ts,json,md,yaml,yml,html,css}": "prettier -w", - "*.ts": "eslint --fix" + "*.{js,mjs,ts}": ["eslint --fix", "prettier -w"], + "!(*.{js,mjs,ts})": "prettier -w --ignore-unknown" } diff --git a/.mocharc.json b/.mocharc.json deleted file mode 100644 index b758d165..00000000 --- a/.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/.npmignore b/.npmignore deleted file mode 100644 index c19ccacb..00000000 --- a/.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/.nvmrc b/.nvmrc new file mode 100644 index 00000000..b832e400 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.16.0 diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 9bfa7ea7..00000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -# Coverage directory used by c8 -coverage diff --git a/.releaserc.json b/.releaserc.json index 3122a54b..4f160d50 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1 +1,148 @@ -{ "extends": "@binden/semantic-release-config" } +{ + "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/exec", + { + "verifyReleaseCmd": "node scripts/release.mjs verify ${nextRelease.version}", + "prepareCmd": "node scripts/release.mjs version ${nextRelease.version} && node scripts/release.mjs publish \"$RELEASE_DIR\" --dry-run", + "publishCmd": "node scripts/release.mjs publish \"$RELEASE_DIR\"" + } + ], + "@semantic-release/github" + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e02065..3d23875e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,183 +1,13 @@ # Changelog -## [2.0.4](https://github.com/binden-js/binden/compare/v2.0.3...v2.0.4) (2025-04-27) +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). -### Dependencies +The history of the earlier versions is kept in the changelog of each package: -- bump @binden/logger from 2.0.1 to 2.0.3 ([c8ce978](https://github.com/binden-js/binden/commit/c8ce978ffe84d8464490a11a6f3f3305c9ff4309)) - -## [2.0.3](https://github.com/binden-js/binden/compare/v2.0.2...v2.0.3) (2024-06-29) - -### Dependencies - -- bump @binden/logger from 2.0.0 to 2.0.1 ([7dc2ed3](https://github.com/binden-js/binden/commit/7dc2ed38b5e44804cb893a5e760448c4c02b2323)) - -## [2.0.2](https://github.com/binden-js/binden/compare/v2.0.1...v2.0.2) (2024-02-01) - -### Bug Fixes - -- add missing files to the `npm` package ([d8db0fe](https://github.com/binden-js/binden/commit/d8db0feb894595e70d5aad8a6ef0147b3daa0cfe)), closes [#150](https://github.com/binden-js/binden/issues/150) - -## [2.0.1](https://github.com/binden-js/binden/compare/v2.0.0...v2.0.1) (2024-01-31) - -### Dependencies - -- bump @binden/logger from 1.0.13 to 2.0.0 ([eacc604](https://github.com/binden-js/binden/commit/eacc604ae4b68f8ff5a242d74d5eb018a36ab6fa)) - -## [2.0.0](https://github.com/binden-js/binden/compare/v1.2.5...v2.0.0) (2024-01-30) - -### ⚠ BREAKING CHANGES - -- drop `Node.js` `<20.11.0` support - -### Performance Improvements - -- drop `Node.js` `<20.11.0` support ([0f6f259](https://github.com/binden-js/binden/commit/0f6f2597443ca522c77d7537467af16ddc70c4be)) - -## [1.2.5](https://github.com/binden-js/binden/compare/v1.2.4...v1.2.5) (2023-12-30) - -### Dependencies - -- bump @binden/logger from 1.0.11 to 1.0.13 ([65b4006](https://github.com/binden-js/binden/commit/65b4006e37ac11758ca77e221c6a8c3f97141418)) - -## [1.2.4](https://github.com/binden-js/binden/compare/v1.2.3...v1.2.4) (2023-11-27) - -### Dependencies - -- bump @binden/logger from 1.0.10 to 1.0.11 ([2559214](https://github.com/binden-js/binden/commit/2559214ea64e264cf1ad9bfba1eecf9d09fa1992)) - -## [1.2.3](https://github.com/binden-js/binden/compare/v1.2.2...v1.2.3) (2023-09-15) - -### Dependencies - -- bump @binden/logger from 1.0.9 to 1.0.10 ([cd189b9](https://github.com/binden-js/binden/commit/cd189b97051430c009303155edfaef9881a4ab02)) - -## [1.2.2](https://github.com/binden-js/binden/compare/v1.2.1...v1.2.2) (2023-09-07) - -### Dependencies - -- bump @binden/logger from 1.0.8 to 1.0.9 ([7e5feed](https://github.com/binden-js/binden/commit/7e5feed90de43f283e3770b2d53f376b02187071)) - -## [1.2.1](https://github.com/binden-js/binden/compare/v1.2.0...v1.2.1) (2023-05-29) - -### Dependencies - -- bump @binden/logger from 1.0.7 to 1.0.8 ([a40164c](https://github.com/binden-js/binden/commit/a40164c7d13312f272a0e69a8cb2ed90f7ff3e14)) - -## [1.2.0](https://github.com/binden-js/binden/compare/v1.1.7...v1.2.0) (2023-02-28) - -### Features - -- allow to use functions as middlewares ([10333cb](https://github.com/binden-js/binden/commit/10333cbc0aad25a833ef7d4903dc4c537e0c1efa)) - -### Dependencies - -- bump `@binden/logger` from `1.0.6` to `1.0.7` ([6a7611b](https://github.com/binden-js/binden/commit/6a7611b7e9bc06a475544bce578b72904b1ebd66)) - -## [1.1.7](https://github.com/binden-js/binden/compare/v1.1.6...v1.1.7) (2023-02-12) - -### Bug Fixes - -- **Context:** allow to pass a custom `stringify` function to `.json()` ([f9317b9](https://github.com/binden-js/binden/commit/f9317b9c217c08c9d2db8cf623d7da5179a10463)) - -## [1.1.6](https://github.com/binden-js/binden/compare/v1.1.5...v1.1.6) (2023-02-12) - -### Performance Improvements - -- **BindenResponse:** allow to pass a custom `Stats` to `.sendFile()` ([c3a9a8e](https://github.com/binden-js/binden/commit/c3a9a8e042e3c6937751bb2f8a7b3d67ceb6e361)) -- **BindenResponse:** allow to pass a custom `stringify` function to `.json()` ([e90a580](https://github.com/binden-js/binden/commit/e90a5803ea5175e44013da667b555b1d8fe07f24)) - -### Dependencies - -- bump `binden/logger` from `1.0.5` to `1.0.6` ([9333718](https://github.com/binden-js/binden/commit/93337181f98f7367d574c1889e09481022d8849e)) - -## [1.1.5](https://github.com/binden-js/binden/compare/v1.1.4...v1.1.5) (2022-11-08) - -### Dependencies - -- bump @binden/logger from 1.0.4 to 1.0.5 ([236f61c](https://github.com/binden-js/binden/commit/236f61cddfe8fb6e79eec4d269a0f78aca52cc8b)) - -## [1.1.4](https://github.com/binden-js/binden/compare/v1.1.3...v1.1.4) (2022-10-09) - -### Dependencies - -- bump @binden/logger from 1.0.3 to 1.0.4 ([0a459d1](https://github.com/binden-js/binden/commit/0a459d1baeb7799b1a2d4cabc3e8eb885d98dd6a)) - -## [1.1.3](https://github.com/binden-js/binden/compare/v1.1.2...v1.1.3) (2022-09-19) - -### Performance Improvements - -- add generics to the `BindenResponse` class ([8315288](https://github.com/binden-js/binden/commit/8315288246d57a97dfecf20f120ac1b8c01adeba)) - -## [1.1.2](https://github.com/binden-js/binden/compare/v1.1.1...v1.1.2) (2022-09-06) - -### Dependencies - -- bump @binden/logger from 1.0.2 to 1.0.3 ([3c5c20b](https://github.com/binden-js/binden/commit/3c5c20bdc12dade5237ab22de5e54407a8ceb79c)) - -## [1.1.1](https://github.com/binden-js/binden/compare/v1.1.0...v1.1.1) (2022-08-30) - -### Dependencies - -- bump @binden/logger from 1.0.0 to 1.0.2 ([e026a4f](https://github.com/binden-js/binden/commit/e026a4f45d373bb065ee9060414f9853c1932313)) - -## [1.1.0](https://github.com/binden-js/binden/compare/v1.0.0...v1.1.0) (2022-08-19) - -### Features - -- **BindenError:** add the `cause` option ([986a52c](https://github.com/binden-js/binden/commit/986a52cf797568a3ef9aca6cc9aefcf74e8d2bd7)) - -## 1.0.0 (2022-08-16) - -### ⚠ BREAKING CHANGES - -- rename `Kauai` to `Binden` -- drop Node.js `<18.7.0` support - -### Features - -- add `Context` ([ebfc75d](https://github.com/binden-js/binden/commit/ebfc75da9037280b9f3ebfbb8c301fe5f6ddf30f)) -- add `Kauai` ([dab27ab](https://github.com/binden-js/binden/commit/dab27ab82e5995b2a062b69d4d1308abc48c9e28)) -- add `KauaiError` ([9498106](https://github.com/binden-js/binden/commit/949810618e9f96b178376a8604d9c4e146d0a701)) -- add `KauaiRequest` ([4986039](https://github.com/binden-js/binden/commit/4986039f9050e3c2619a6ee73781958a457e8f3b)) -- add `KauaiResponse` ([2313985](https://github.com/binden-js/binden/commit/23139857603b301d40a4bb3502b4245384d127b0)) -- add `Middleware` ([86e96fb](https://github.com/binden-js/binden/commit/86e96fb548ce68de8294626eaf0f4ffbdfa7ab82)) -- add `Router` ([55f2780](https://github.com/binden-js/binden/commit/55f27806fdb7499b5174f8f44303aa4f31f00a97)) -- add logger ([7157667](https://github.com/binden-js/binden/commit/7157667f7506c2b6f393454294c5edbbf382e7d0)) -- **Headers:** add `Content-Encoding` ([82d1cc2](https://github.com/binden-js/binden/commit/82d1cc25aa43cdeb1e1da288002923768a632ed4)) -- **Headers:** add `Content-Type` ([f9fc897](https://github.com/binden-js/binden/commit/f9fc8974c9d76eb09005169462edd8f7b3892bf9)) -- **headers:** add `Forwarded` ([abe665c](https://github.com/binden-js/binden/commit/abe665cdf00d0ad1957d6682e4e1dd824ddf0d02)) -- **Headers:** add `If-Modified-Since` ([1a894b9](https://github.com/binden-js/binden/commit/1a894b99806eb37ba21400aaa5fcbff49022425b)) -- **headers:** add Content-Range ([a0f68a0](https://github.com/binden-js/binden/commit/a0f68a0d8621690229be04389ad6d921ce529fca)) -- **headers:** add Cookie ([e78474e](https://github.com/binden-js/binden/commit/e78474ef61dbdf62255a3b03f767d9c27542a110)) -- **headers:** add Range ([2473776](https://github.com/binden-js/binden/commit/247377654c233ce78f32437ffd91f646124439a0)) -- **Headers:** add the `Accept-Encoding` header ([a26a658](https://github.com/binden-js/binden/commit/a26a6589cc1d3dd504fc395ec8d905256d49c07f)) -- **KauaiRequest:** parse the `Cookie` header ([4c4fb44](https://github.com/binden-js/binden/commit/4c4fb4427555e1c311570d214ee9cbd81555582e)) -- **KauaiRequest:** parse the `Forwarded` header ([bfb9080](https://github.com/binden-js/binden/commit/bfb908069147a6b76d1fa565a3a0981e6b90d176)) -- **KauaiRequest:** parse the `Range` header ([a555d7e](https://github.com/binden-js/binden/commit/a555d7ebbecb81a6dc55ef117658a7f257f74e5a)) - -### Bug Fixes - -- **KauaiResponse:** allow the `.json()` method to accept arrays ([8637ac8](https://github.com/binden-js/binden/commit/8637ac8a527340eb411826000f7c33a5e81e2c0e)) - -### Performance Improvements - -- add `id` to the `KauaiRequest` class ([8b2048c](https://github.com/binden-js/binden/commit/8b2048cdda85dba7d3b05824b074b41ded8a1b2e)) -- **Context:** add the `.setHeader()` method ([4e48d2e](https://github.com/binden-js/binden/commit/4e48d2e2cde9dd0deb7c9805a2feea962d78a7db)) -- drop Node.js `<18.7.0` support ([7cb854e](https://github.com/binden-js/binden/commit/7cb854ee216e7b02a3d4cf942afc05a93cbfbf8b)) -- **Headers:** add the `Authorization` header ([dc53f28](https://github.com/binden-js/binden/commit/dc53f28a5bd7267f8eca3cdf640993afb5c34a45)) -- **sendFile:** add partial request support ([b319879](https://github.com/binden-js/binden/commit/b31987905e553393cf71d5a62930ecaa06d054a4)) - -### Dependencies - -- add `@binden/logger` ([e38d95f](https://github.com/binden-js/binden/commit/e38d95f95eb2e85f2c0a40ce65d50bbd610d543c)) -- add `@kauai/logger` ([998056b](https://github.com/binden-js/binden/commit/998056b3dc208ac2b84546bf3227af4cbf8f9a1b)) -- add `pino` ([6a449ac](https://github.com/binden-js/binden/commit/6a449ac25e64be022752ea4b7b5c9cdef91cb631)) -- remove `pino` ([753452c](https://github.com/binden-js/binden/commit/753452cbeb68ebab380d161ec84fd83714c0c3b0)) -- upgrade `@kauai/logger` to `v1.0.1` ([5d500df](https://github.com/binden-js/binden/commit/5d500df1aeec5b3303a163ec6b5ff3bf945014b2)) -- upgrade `pino` to `v6.12.0` ([eb8d21f](https://github.com/binden-js/binden/commit/eb8d21f49a8367fdeee2f87a3613c98935750629)) -- upgrade `pino` to `v6.13.0` ([13f7e8a](https://github.com/binden-js/binden/commit/13f7e8a92d4445db662dd7c2250dffacd845394a)) - -### Miscellaneous Chores - -- rename `Kauai` to `Binden` ([17b83ed](https://github.com/binden-js/binden/commit/17b83ed23df79a1baf934742af9677a1c440651d)) +- [`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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f9554ca6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# 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. +- **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: + +```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 required checks of `main`, see + [Repository settings](README.md#repository-settings)): the lint job (formatting, commits, ESLint, + the tooling tests, the acceptance scenarios and the docs), the package job (the release + rehearsal on the runtime of the releases, the packed files and the consumer check), 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). diff --git a/MIGRATION-4.md b/MIGRATION-4.md new file mode 100644 index 00000000..a40261dd --- /dev/null +++ b/MIGRATION-4.md @@ -0,0 +1,954 @@ +# 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). +The packages no longer declare an `npm` version in their `engines`: any npm that supports Node.js +24 installs them. + +### 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 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 supported TypeScript setups are `@types/node` 24, type checked strictly, and `@types/node` 26 +with `skipLibCheck: true`: with `@types/node` 26, the types of `thread-stream` (used by `pino`, the +logger of `@binden/logger`) do not compile, so `skipLibCheck: false` fails (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. + +So does `new BindenError()` (and `context.throw()`): an `expose` that is not a boolean, a `json` +that is not a plain object (e.g. an array, which was sent as an object with numeric keys) or a +`message` that is not a string throws a `TypeError`. Its `json` is copied once, by the +constructor, which throws what reading it throws: binden 2.0.4 kept the object and copied it on +every read of `error.json`, so that a later change of the object showed, and its getters ran on +each read. + +`new Context()` throws a `TypeError` for a `request` that is not a `BindenRequest`, a `response` +that is not a `BindenResponse`, and a `report` that is not a function: binden 2.0.4 accepted any +request (it read its `id`) and any response. The request and the response must each be an +instance made by its constructor, or the one of a subclass: a proxy of one, an object of another +realm, or one made with `Object.create()` of its prototype is refused too. + +Options that are neither an object nor `undefined` throw a `TypeError` ("`options` is not an +object") in `new Binden()`, `createServer()`, `createSecureServer()`, `new Router()`, the +constructor of every `Middleware` and `new BindenError()` (also through `context.throw()`): binden +2.0.4 read a string or a number as an object without options, e.g. +`new BindenError(404, "Not found")` replied with the default message, `Not Found`. A function is +not an object of options either ("`options` is not an object: (a function)"), also one that +carries the options as its properties (which TypeScript accepts for the option types): binden +2.0.4 read its properties as the options. + +```diff +-throw new BindenError(404, "Not found"); ++throw new BindenError(404, { message: "Not found" }); +``` + +`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 committed its headers. Its `send()` of a stream or `sendFile()` that has not is cancelled + first, as by a deadline (it rejects with the failure of the handler, or with + `BindenError(500, { code: "error_handler_timeout" })` once the handler has timed out), and so is + one that the stack has left pending: the `500` replaces them. + +```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 }); + }, +}); +``` + +Once a request is abandoned (its deadline has passed, or its error handler has failed or timed +out), the work that still runs can no longer change its response: also the methods of Node.js +(`write()`, `end()`, `writeHead()`, `setHeader()`, `destroy()`, `setTimeout()`, …), `statusCode`, +`sendDate`, `shouldKeepAlive` and the cookies do nothing (a callback of `write()` or `end()` is +still called), so that a late middleware cannot replace or cut the reply of the error handler. An +array of the values of a header is copied when it is set or read (Node.js keeps the array itself). A deadline that passes before a `send()` of a stream +or a `sendFile()` has committed the headers cancels it (it rejects with the abort reason) and +replies `503`, instead of closing the connection; also while the error handler runs for another +error, whose later reply, failure or time out then changes nothing. A reply that a timed out error handler has +ended is still sent. The `503` of a deadline, the `500` of a failed error handler and the +default error reply close the connection (`Connection: close`) when the body of the request has +not been received in full (`request.complete`). + +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`, …). Each cookie is serialised once, in its own guard (also for the +empty `500` of a failed error handler): one that fails (its `toString()` throws, or Node.js +refuses its value) is dropped from `response.cookies` and logged at `warn`, and the error is +replied with the others. binden 2.0.4 sent the error reply without the cookies (by an `end()`, +which added none), so that it never read them. 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). + +The type and language of the body that the error replaces are removed as well: an exposed +`message` is sent as `text/plain; charset=utf-8` (and `json` as `application/json`), never with +the `Content-Type` that the route had set (e.g. `text/html`, which used to be kept). + +### Pipes and listeners of the response + +The work of a request owns its response through what it adds to it, which changes a few +behaviours of Node.js: + +- **An abandoned work sees its response closed early.** When a work loses the response (its + deadline, a superseded, timed out or failed error handler, a failed pipe), its `close` listeners + on the response (and its `error` listeners, with the error of its failed pipe) run once, on the + next tick: an extension of the `close` event of Node.js. Cleanups that it has registered with + `response.on("close")` run then, not at the real close; its `stream.pipeline()` and + `stream.finished()` of the response settle with `ERR_STREAM_PREMATURE_CLOSE` (or that error). + `context.onCleanup()` and the access log still run at the real close. +- **A legacy `Stream.prototype.pipe()` into the response is refused**: the work that has piped it + fails with a `TypeError` (`500` before the headers), also through `stream.pipeline()`, which + pipes such a stream with its own `pipe()`. Wrap such a stream (e.g. of an old stream library) + with `new Readable().wrap(stream)`, then pipe the wrapper or send it + (`context.send(new Readable().wrap(stream))`). A legacy pipe of a work of no request (or of + another request), which has no `unpipe()`, cannot be dismantled: it is left to Node.js, with a + warning, and writes as the work that feeds it. +- **A pipe that binden cannot attribute is refused** the same way: while the response or the source + has listeners of `newListener` or `removeListener`, when the source is a `Proxy` or has its own + listener methods, `emit()`, `pipe()`, `unpipe()`, `pause()`, `resume()`, `read()`, + `rawListeners()`, `listeners()`, `listenerCount()`, `removeListener()`, `off()` or + `removeAllListeners()` (e.g. patched on an instance; a pipe of the middleware stack whose source + has no method of its own but listener methods is left to Node.js instead, see **OpenTelemetry** + below), an accessor of its own of one of them or of `_readableState`, `_writableState`, `_events`, + `_eventsCount`, `_maxListeners`, `readableEnded` or `destroyed`, or when it is not extensible + (`Object.seal()`; Node.js itself cannot pipe a frozen stream). The class of the source (also of + `readable-stream`) is trusted, with the whole prototype chain of the source, accessors included: + only the own properties of the source are checked. Piping a stream that another work has patched + (its class, any prototype of its chain, e.g. one inserted with `Object.setPrototypeOf()`, with its + methods or accessors, or its own methods, which refuse the pipe once it is set up) runs that code + as the work that pipes. +- **OpenTelemetry**: its `http` instrumentation binds every request and client response to its + context (`context.bind()`: listener methods of their own). A native pipe of such a stream by a + middleware (e.g. `request.pipe(response)` or `upstream.pipe(response)`, also through + `stream.pipeline()`) is left to Node.js, as without binden: a write or an end of the response that + throws in it is not caught (it is thrown out of the events of the stream, as with Node.js: e.g. an + uncaught exception for a head that Node.js refuses), and any revocation of the stack during it + (its deadline, or the failure of a pipe of its own) closes the connection instead of replying + `503` (or `500`) unless its response has ended, also once the stream has ended (e.g. piped with + `{ end: false }`). In an error handler, such a pipe is refused (`500`). Send such a stream with + `context.send(stream)` (or pipe `new Readable().wrap(stream)`), which keeps those replies and + fails the work for those throws. +- **A pipe whose write or end throws fails its work** (e.g. a reason phrase or a `Trailer` that + Node.js refuses; not a pipe left to Node.js, see **OpenTelemetry** above): instead of a destroyed + response (or a crash, from its end), the error handler replies before the headers, and the + connection is closed afterwards (but a complete reply that waits for the response before it on + its connection is sent); `stream.pipeline()` rejects with the error, and so does every pending + `send()` of a stream or `sendFile()` that has not committed the headers (it is cancelled, as by a + deadline, also one that the stack has left pending). A source piped by an error handler that + errors or closes before its end fails the handler at once, as does a destroy of the response by + its `stream.pipeline()` before the headers (also when one of its streams fails from a timer of no + request): `500`, not a closed connection. +- A listener added to the source in the turn of its pipe runs as the work that has added it + (e.g. one added by a listener of `pipe` of another work gets no rights from the pipe); one added + later runs as the work that emits its event. +- `response.rawListeners()` has the listeners as Node.js has them (the listener itself, or the + wrapper of a `once()`), and every listener and callback gets the receiver that Node.js gives it. + +### 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`). +- `OPTIONS *` gets `204 No Content` with the methods of every router of the application in + `Allow`, without running the stack (it ran the stack with the path `/*`). +- 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). +- binden tells a context by its private brand: a result of a middleware that is not one, also an + object derived from its context (e.g. `Object.create(context)`) or a proxy of it, is ignored, as + any other value, and the next middlewares get the current context. binden 2.0.4 took such a + result for a context: an object derived from it, or a proxy without traps + (`new Proxy(context, {})`), failed the request (`500`, a `TypeError` of V8 for a private + member), while a proxy whose traps forward to the context itself (e.g. a `get` that reads the + context, as @binden/compression 3.0.0 returned to replace its `response`) replaced the context: + the next middlewares got the proxy. binden ignores that proxy silently. To change what the next + middlewares get, change the context or its response itself (e.g. replace methods of + `context.response`, as @binden/compression 4 does, or set properties of `context.state`), or + return a `Context` (e.g. of a subclass) of the same request and response. +- `use()` throws a `TypeError` without a middleware or router, or when its first argument is + neither one nor a path (a string or a RegExp): `use(stack)` and `use(undefined)` were silently + ignored. A stack is added with `mount()`. +- A RegExp path of `use()` keeps its flags (but `g` and `y`, which make `test()` stateful): it was + compiled again as `new RegExp(path, "u")`, which dropped its flags and added `u`. A flag such as + `i` now applies, and a RegExp that needs `u` (e.g. `/^\/\p{L}+$/u`, whose `\p{L}` is a literal + `p{L}` without it) must have it. + +### 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` and `204` responses have none, and a response to `HEAD` (or with a `1xx`, `204`, + `205` or `304` status) sends no body. The `304` of `send()` (with the `etag` option) and of + `sendFile()` keeps the `Content-Length` and the `Content-Type` of the `200` it stands for; a + `304` that the application sends itself has no `Content-Length`. 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()`. +- `response.cookies.add()` throws a `TypeError` ("`cookie` is not an object: …") for anything but + an object, also a string: binden 2.0.4 added any value, then failed as the head was sent + (`null`, `undefined`), or sent it converted as a `Set-Cookie` (a string as it is, a symbol as + `Symbol(…)`). Add a `Cookie` (or an object whose `toString()` gives the value). +- A `write()` or an `end()` that Node.js throws from once the head is committed (e.g. a body that + does not have its `Content-Length` under `strictContentLength`) closes the connection: the + response is destroyed before the error is thrown, so that nothing that it has queued is flushed, + also for a later `write()` past the length, which Node.js refuses and keeps the response usable. + `ERR_HTTP_BODY_NOT_ALLOWED` and the checks of a chunk (`ERR_INVALID_ARG_TYPE`, + `ERR_STREAM_NULL_VALUES`, `ERR_UNKNOWN_ENCODING`) leave it usable, as with Node.js. Under + `strictContentLength`, a first `write()` past a valid `Content-Length` throws + `ERR_HTTP_CONTENT_LENGTH_MISMATCH` before anything is committed, where Node.js 24 and 26 and + binden 2.0.4 send the whole chunk (the client reads its excess as the next response). +- A `Content-Length` that is neither a number nor a string (e.g. `true`, `null`, a `bigint` or an + object with a `toString()`), or an array with such an element or a hole, makes the call that + commits the head (`writeHead()`, a first `write()`, `end()` or `flushHeaders()`) throw a + `TypeError` (`ERR_HTTP_INVALID_HEADER_VALUE`, "Invalid Content-Length: …", the value described, + `undefined` for a hole), and a `send()` of a stream rejects with it before anything is sent (the + error can be replied): binden 2.0.4, as Node.js, sent it converted (e.g. `Content-Length: 5` for + `{ toString: () => "5" }`) without counting the body by it, so that a body of another length + desynchronized the connection (the client read the response after it as part of the body), and + threw an unrelated `TypeError` for a `bigint`. Set a number or a string. @binden/compression + refuses it in the same way, before it decides. +- `writeContinue()`, `writeProcessing()`, `writeEarlyHints()` and `writeInformation()` throw + `ERR_HTTP_HEADERS_SENT` once the head is committed, on every version of Node.js: on Node.js + before 24.18, binden 2.0.4 (as `node:http`) wrote the raw `1xx` head of the first three into the + body, which desynchronized the connection (Node.js 26 throws). A call that is ignored (by a work + that can no longer change the response, or for an HTTP/1.0 client) is still ignored. +- `Content-Length` values that disagree (`["2", "3"]`, or two `appendHeader()` calls) now throw a + `TypeError` (`ERR_HTTP_INVALID_HEADER_VALUE`, "Content-Length values disagree") when the head is + committed, where Node.js and binden 2.0.4 sent every line. So does any comma list, even one whose + members agree (`2, 3`, but also `2, 2` and `2,2`, alone or as an element of an array), with + "Content-Length is a list": Node.js sends it as it is, and cannot read it. +- `send()` and `sendFile()` with a `1xx` status reject with a `TypeError` (use + `writeEarlyHints()`), also one assigned as a string (e.g. `"103"`), which binden converts once as + Node.js does and names in the message ("Status code 103 is informational"), before they set + anything (`sendFile()` before it reads its options or opens its file): binden 2.0.4 sent it as + the final response (`sendFile()` as `103 Early Hints` with the `Content-Length` of its file), so + that the client took the next response of the connection for the reply. A string sent with an `encoding` is encoded once: the bytes that it + decodes to are sent, with their length. + A `HEAD` response without a body gets `Content-Length: 0`, as for `GET`, and `send("")` gets the + default `Content-Type` and the dynamic `ETag` of an empty body, as an empty `Buffer`. A `Readable` + that has ended is an empty body. +- A value `send()` (also `text()`, `html()`, `json()`, `form()`) whose head Node.js refuses (e.g. a + status out of range or an invalid `statusMessage`) leaves the response as it was before it: + binden 2.0.4 kept the `Content-Type` that it had set (e.g. `plain/text` of `send()` and `text()`, + `application/json` of `json()`) for the reply that followed. Set the type of that reply yourself. + It also removes a `Content-Length` or a `Transfer-Encoding` that the application had set before + it (whose body it was), so that the reply that follows frames its own body: `send()` and the + default error reply by their own `Content-Length`, a reply of `write()` and `end()` chunked once + a `Content-Length` is removed, and once a `Transfer-Encoding` is removed by its length (a single + `end(chunk)`) or by the close of the connection. binden 2.0.4 kept them for that reply: a + `Content-Length: 5` set before was sent with the 3 bytes of an `end("err")` that followed (the + client waited for the rest of the body), and a `Transfer-Encoding: chunked` kept it chunked. +- A `sendFile()` that fails before the head is committed, once it has set the headers of the file + (e.g. a head that Node.js refuses), restores the response as it was before it, so that the reply + that follows (e.g. of the error) gets none of the headers of the file, and it opens the file + before it sets them. binden 2.0.4 set them first: a file that `stat()` read but that it could not + open (e.g. one of mode `000`) left its `Last-Modified`, `Accept-Ranges` and `Content-Length` to + the reply of that failure (`EACCES`), so that the client read the start of the next response of + the connection as the body that the reply did not have. +- A `Readable` that has been destroyed, or that has errored without being destroyed (its + `errored` is set, e.g. a stream without `autoDestroy`), rejects `send()` at once, with its + `errored` (otherwise `ERR_STREAM_PREMATURE_CLOSE`). binden 2.0.4 rejected only when the error of + the stream had not been emitted yet (e.g. a `destroy(error)` in the same tick as `send()`), and + otherwise never settled. +- `send()` of a stream resolves once the response is flushed to the connection, where binden 2.0.4 + resolved at the end of the stream (its data could still be buffered), and it rejects when the + client goes away before: with the error of the connection when a write fails first (e.g. + `EPIPE`, `ECONNRESET` or `ECANCELED`), otherwise as a premature close + (`ERR_STREAM_PREMATURE_CLOSE`). The send of binden 2.0.4 never settled then, unless the stream + failed by itself (e.g. the request of an echo, with `ECONNRESET`). +- A `send()` that is ignored (once the response has ended, or by a work that can no longer change + it, e.g. once its deadline has passed) releases a Node.js `Readable` without reading it: it is + destroyed (closing the file of a stream), where binden 2.0.4 left it untouched. The request of + the response itself (`send(context.request)`) is never destroyed. A `sendFile()` whose response + has ended before it sends anything (before it is called, or while it opens its file) resolves + without sending anything, where binden 2.0.4 rejected with `ERR_HTTP_HEADERS_SENT` (or with the + error of its `stat()`). +- `send()` takes a Node.js `Readable` over: it is destroyed without being read when nothing of it + would be sent (to `HEAD`, with a `204`, `205` or `304` status, and with a `1xx` status, which + rejects), and once the client goes away, also when the stream is shared (e.g. one `PassThrough` + sent to several responses: the others lose it too), where binden 2.0.4 piped it (reading it for + a response without a body) and only unpiped it from a response that the client had left. A + shared stream is also destroyed, where binden 2.0.4 delivered it whole to the other responses, + when another writer ends the response (e.g. its own `end()`), and when binden fails the send + itself (e.g. a body longer than its `Content-Length`): the stream is destroyed with that error, + which the other sends of the stream reject with. Send each response a stream of its own (e.g. a + `PassThrough` that the shared stream is piped into). The request of the response itself is only + no longer read. +- A source that fails once the response has ended no longer rejects `send()`, whether another + writer (e.g. its own `end()`) or the send itself, with the whole body, has ended the response: + the reply is sent whole, and the responses after it on the connection too, and the error is + logged at `debug`. binden 2.0.4 rejected with that error when another writer had ended the + response, and its default error handler, which destroys the connection of a response that has + ended, then cut the reply (the pipelined responses after it were never sent). It resolved the + send of a whole body once its stream had ended (binden 4 resolves once the response is flushed), + and ignored a later error of the stream. +- `send()` (and `context.send()`) rejects with a `TypeError`, before anything is sent, for an + object that is neither a `Buffer`, a `Uint8Array`, a `Readable` nor a stream-like source: it sent + its `toString()` (e.g. `[object Object]`, or the text of a `URLSearchParams`). Send a string + instead (e.g. `send(String(value))`, or `form()` for `URLSearchParams`). A stream that is not a + Node.js `Readable` (e.g. of `readable-stream` or `through2`: an object with `on()`, `pause()`, + `resume()`, and `removeListener()` or `off()`) is sent as a stream (it sent `[object Object]`), + and a `Uint8Array` is sent as its bytes (it sent its numbers, separated by commas). +- `text()`, `html()`, `form()` and `json()` of a response that has ended resolve without doing + anything (without reading or checking their arguments: `json()` runs no `stringify`), as `send()` + did and does: binden 2.0.4 set their `Content-Type` first, so that `text()`, `html()` and `form()` + threw `ERR_HTTP_HEADERS_SENT` synchronously, and `json()` rejected with it. Of a response whose + head is sent but that has not ended (e.g. after a `write()`), they reject with + `ERR_HTTP_HEADERS_SENT`, also `text()`, `html()` and `form()`, which threw it synchronously: await + them (a `try` around a call that is not awaited no longer catches it). +- `form()` sends only `URLSearchParams`, serialized by `URLSearchParams.prototype.toString()`: + binden 2.0.4 sent `params.toString()` of whatever it was given (e.g. a string as it is, or the + override of a subclass). Anything else now rejects with a `TypeError` ("The parameters are not + URLSearchParams"); send a string with `text()` or `send()` and the `Content-Type` that it needs. +- `response.set()` sets the own enumerable properties of its object (by `Object.entries()`), and + throws a `TypeError` ("`headers` is not an object") for anything but an object, also a function. + binden 2.0.4 set every enumerable property by `for…in`, also an inherited one (e.g. of + `Object.create(defaults)`): it did nothing for `null`, `undefined` or a number, set the headers + `0` and `1` for `"ab"`, and set the enumerable properties of a function. Copy inherited headers + into the object (e.g. `set({ ...defaults, ...headers })`). +- The `q_value` of an `AcceptEncoding` must be a number or `null`: binden 2.0.4 kept any value (e.g. + `"0.5"`) as its `q_value`, but serialized only a number (`toString()` dropped the weight of any + other value, e.g. `gzip` for `"0.5"`). Anything else now throws a `TypeError` ("`q_value` is not a + number"). +- `response.type()` throws a `TypeError` for a parameter that is not a token with a token or a + quoted string as value (e.g. `text/plain; charset`), which it used to drop. +- With `root`, `sendFile()` replies `404` for a socket, a device or a FIFO, without opening it. +- 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, a space or a control character, 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 ContentType({ charset })` throws a `TypeError` for a `charset` that is neither a string nor + `null` or `undefined` ("`charset` is not a string: …"), for every media type: binden 2.0.4 + dropped it. +- `ContentType` requires the `boundary` of `multipart/form-data` in any case of the media type + (e.g. `Multipart/Form-Data`), and refuses a parameter value that is not a valid field value (a + control character, or a character above `\xFF`): so does `response.type()`. +- `new ContentRange({ size: "*" })` without a range throws a `TypeError`: an unsatisfied range + needs the complete length (`bytes */`). +- `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. +- The constructor of every header throws a `TypeError` for an init that is not an object + ("`init` is not an object: …"): binden 2.0.4 read a string as an object without its properties + (e.g. `new Range("bytes=0-1")` was `bytes=-`), or threw an unrelated error. A function is not an + init either: binden 2.0.4 read its properties as the init (e.g. `new Range(init)` of + `Object.assign(() => {}, { start: 0, end: 1 })` was `bytes=0-1`). +- `Cookie.fromString()`, `Range.fromString()` and `IfModifiedSince.fromString()` throw a + `TypeError` for anything but a string or `undefined` ("The header is not a string: …"), where + binden 2.0.4 returned no cookies, no ranges or `null` for e.g. `null`, a number or an array. So + do `request.cookies`, `request.range` and `request.if_modified_since` for such a value that the + application has put into `request.headers`. +- `AcceptEncoding.fromString()` throws a `TypeError` for an element of an array that is not a + string ("A field line is not a string: …"), which binden 2.0.4 converted with `join()` (e.g. by + its `toString()`). +- `AcceptEncoding.fromString()`, `Authorization.fromString()`, `ContentEncoding.fromString()`, + `ContentRange.fromString()`, `ContentType.fromString()` and `Forwarded.fromString()` throw a + `TypeError` for a `String` object (e.g. "The header is not a string: (an object)"), which binden + 2.0.4 parsed as its string: so do `request.content_type` and `request.authorization` for a + `String` object that the application has put into `request.headers`. Give a string (e.g. + `String(value)`). +- `new IfModifiedSince({ date })` takes a `Date`, a number or a string: `null` and a boolean (1970 + for binden 2.0.4, e.g. `{ date: true }`), `undefined` (an Invalid Date for binden 2.0.4) and + other objects (converted, e.g. by their `valueOf()`) throw a `TypeError` ("The date is not a + Date: …"). +- `request.header()` and `router.middlewares()` throw a `TypeError` for a name or a method that is + not a string, where binden 2.0.4 accepted a `String` object, or anything with a `toLowerCase()` + (a `toUpperCase()` for `middlewares()`). + +#### 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. The listener tells the classes of +the application by their private brands: a proxy of a request or a response, an object made with +`Object.create()` of their prototypes and an instance of the classes of another application are +refused as well. + +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`. A logger + derived from it (`Object.create(context.log)`) or a proxy of it works as a custom logger, but + the methods that it overrides apply only to the calls made on it: the records of a request go + through its `child()`, which gives a child of the original logger. Override `child()` as well + to change them, or pass a custom logger. +- The framework writes its own records by a private path of its loggers: a method replaced on + `context.log` (e.g. a wrapper that adds a field) no longer sees them, as it did in 2.0.4. Wrap + the logger given as `logger` instead, or override its `child()`. + +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 }); +``` + +The stack of a `BindenError` is formatted in its constructor (see +[Lifecycle](https://github.com/binden-js/binden/tree/main/packages/binden#lifecycle)), so that +an error that is kept retains nothing of its frames: its header (`BindenError: `) is +fixed then. A `message` or a `name` that the application changes later, and an +`Error.prepareStackTrace` installed later (e.g. by `source-map-support` or an APM agent; one +installed before still formats it), no longer apply to its `stack`, which binden 2.0.4 left to +V8 to format when it was first read. Pass the message through the options, or create an +instance of a subclass, whose stack the constructor of `BindenError` does not format, so that +its own constructor gives it its header (e.g. its `name`): + +```typescript +import { BindenError } from "binden"; + +// `error.message = "No user 42"` after `new BindenError(404)` no longer changes its stack +const notFound = new BindenError(404, { message: "No user 42" }); + +class ValidationError extends BindenError { + public constructor(message: string) { + super(400, { message }); + this.name = "ValidationError"; + } +} + +// Its stack starts with "ValidationError: Invalid email" +const invalid = new ValidationError("Invalid email"); +``` + +## @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. +- `new Logger()` throws a `TypeError` for options that are neither an object nor `undefined` + ("`options` is not an object: …"), also a function: @binden/logger 2.0.3 read a string or a + number as no options, and the properties of a function as the options. `Logger.getLevel()` + throws a `TypeError` for a name that is not a string, which 2.0.3 converted (e.g. `5` read the + variable `5`). +- `logger` (an existing `pino` logger) and any other option (`destination`, `level`, + `serializers`, `error_details`, …) throw a `TypeError` together: the other options do not apply + to it (`logger` used to be ignored with `destination`, and the other options with `logger`). A + `logger` that is not a `pino` logger, and an unknown level (of the options, of `child()` or set + to `level`), throw a `TypeError` as well. +- `new Logger()` throws a `TypeError` ("`customLevels` has a level that is not a number: …") for + a level of `customLevels` that is not a number. @binden/logger 2.0.3 handed it to `pino`, which + took a numeric string (`"35"`) or a `bigint` (`35n`) as that level, wrote the records of a level + given as an object with `valueOf()` with `"level": null` (it converted that object as the logger + was created, and as it compared levels, e.g. once its `level` was set, never for a record), and + silently accepted any other value (e.g. `"abc"`, `null`, `true`), whose records it never wrote. + Give the levels as numbers. A path of `redact` that is not a string already threw in 2.0.3 (an + `Error` of `pino`, "Paths must be (non-empty) strings"): it is now a `TypeError` of + @binden/logger ("`redact` has a path that is not a string: …"), thrown before the value is + converted. +- `new Logger()` reads each option once, as `pino` reads it, and gives `pino` that copy: also + `hooks`, each hook once, so that a hook set on that object once the logger is created is not + used, where @binden/logger 2.0.3 gave `pino` the object itself, which used a `streamWrite` set + later for each record, and (with its `pino` 9) a `logMethod` set later for each child that the + application made afterwards (e.g. one per request). Set the hooks before the logger is created. +- The `err` key, under which `pino` logs an `Error` given as the fields of a record, has the + default `error` serializer too: the serializer of `pino` logged the message, the stack and + every property of the error. +- 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`) | +| A parser that starts once the response has finished | an empty body | `500` | +| Data after the end of a content coding | ignored | `400` | + +- 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`. +- `new BodyParser()` throws a `TypeError` for options that are neither an object nor `undefined` + ("`options` is not an object: …"), also a function: @binden/body 2.0.1 read a string or a + number as no options, and the properties of a function as the options. +- A body that the parser refuses before it has read it in full (e.g. over a limit, or with a coding + or a charset that it does not support) closes the connection after the reply, whoever replies + (the default error reply, or an `error_handler` of the application, also one that echoes the + request with `send(context.request)`). binden 2.0.4 with @binden/body 2.0.1 kept the connection, + and answered the next request on it. +- A logger of the context that throws (its methods, its `child()`, or a `log` getter, e.g. of a + subclass of `Context`) no longer rejects `run()`: the records of the parse are dropped, and the + parse goes on, where 2.0.1 rejected with its error (with an error of its methods where it + logged: a skipped request, an unsupported coding or a body that does not parse; on a decoding + failure that error was uncaught, and the request was never answered). The records go through a + logger of binden made by its private path, so that a `child()` replaced on `context.log` no + longer makes it: wrap the logger given to the application instead. + +```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`, and so do + options that are neither an object nor `undefined` ("`options` is not an object"), also a + function: @binden/multipart 2.0.3 read a string or a number as no options, and the properties + of a function as the options. +- 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). +- A client that goes away rejects `run()` with a `400` of code `aborted`. After the deadline or + during a shutdown, `run()` rejects with the reason of `context.signal` (a `BindenAbortError`), as + `@binden/body` does: after the deadline the framework replies `503`, and a shutdown closes the + connection without a reply. +- A parse that starts once the response has finished (Node.js has then discarded the body) + rejects with a `500`: it was an empty or a malformed form. +- A body with a content coding (a `Content-Encoding` other than `identity`) gets + `415 Unsupported Media Type`: it was parsed without being decoded. +- A body that the parser refuses before it has read it in full (e.g. over a limit) closes the + connection after the reply, whoever replies (the default error reply, or an `error_handler` of + the application, also one that echoes the request with `send(context.request)`). binden 2.0.4 + with @binden/multipart 2.0.3 kept the connection, and answered the next request on it. +- A logger of the context that throws (its methods, or a `log` getter, e.g. of a subclass of + `Context`) no longer rejects `run()`: the records of the parse are dropped, and the parse goes + on, where 2.0.3 rejected with its error (with an error of its methods, for a request that it + skipped, e.g. of another media type). The records go through a logger of binden made by its + private path, so that a method replaced on `context.log` no longer sees them: wrap the logger + given to the application instead. +- 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`. The `DefaultCompression` constant, which named that fallback, is removed. + `Vary: Accept-Encoding` is added to every response with a body. +- A fixed `format` is no longer sent whatever the request accepts: it is the only coding offered, + and a request that does not accept it (or sends no `Accept-Encoding`) gets the response as it is. +- A request that refuses `identity` (`identity;q=0`, or `*;q=0` without `identity`) and accepts + none of the codings gets `406 Not Acceptable`, also for a response that is not compressed (see + below). An encoded response (a `Content-Encoding` set by the application, e.g. of a + precompressed file) gets `406` when the request does not accept its coding (also with an empty + `Accept-Encoding`). A response without a body never does. +- 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. `sendFile()` and the dynamic `ETag` choose the representation before they evaluate + the preconditions: once compressed, a strong `If-Match` fails with `412 Precondition Failed` (a + weak tag never meets it), and `Range` is ignored (the whole compressed file is sent). A client + that needs either sends `Accept-Encoding: identity`. +- `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). +- A response to `HEAD` gets the decision of `GET` by the same body: the chunk of its `end()`, or + the `Content-Length` of a stream that `send()` does not read (without one, a body of unknown + length, never an empty one). +- What the decision reads (the status, the headers of the representation, `skipCompression()`) is + set before the reply starts (`send()`, `sendFile()`, or a first write), which may choose the + representation at once. Once the work of a request is abandoned (e.g. at its deadline), + `skipCompression()` and the `run()` of a compression are ignored. +- `Content-Digest`, `Repr-Digest` and `Digest` are removed from a compressed response and from + the empty `406` and `500` that replace a response: they were kept for a body they no longer + matched. +- A reason phrase, a header or a status that Node.js refuses throws before the decision (or leaves + nothing of it), and the error is replied as without a compression, without compressed bytes. +- A `Content-Length` that is neither a number nor a string (or an array with such an element or a + hole) makes the write that would decide throw (`ERR_HTTP_INVALID_HEADER_VALUE`) before the + decision, as binden refuses it (see [Replies](#replies)): @binden/compression 3.0.0 left it to + binden 2.0.4, which sent it converted. +- `new Compression()` throws a `TypeError` for options that are neither an object nor `undefined` + ("`options` is not an object: …"), also a function: @binden/compression 3.0.0 read a string or + a number as no options, and the properties of a function as the options. + +```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..3dee8469 --- /dev/null +++ b/PRODUCTION.md @@ -0,0 +1,629 @@ +# 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. +- The error handler gets the `503` of a deadline (`error.code === "deadline"`) with + `context.signal` already aborted: its reply must not depend on work bound to the signal, which + would fail at once (the client would get an empty `500`). +- Once the deadline has passed, the work that still runs can no longer change the response (its + writes, headers, status and cookies are ignored, also from the listeners of `context.signal` and + from a `catch` of the work that the signal has stopped), and a stream or a file whose headers + are not sent yet is cancelled, so that the `503` is sent at once. A deadline that passes while + the error handler runs for another error takes the reply over the same way. So does an error + handler that fails (throws or rejects) or times out (`error_handler_timeout`): the sends that have + not committed the headers (also a stream whose first chunk has not come, and one that the stack + has left pending) are cancelled, and its empty `500` replaces them; once a send has committed the + headers, the connection is closed without a reply. An error handler that has ended its reply + when `error_handler_timeout` passes is not cut: a slow client still gets all of it. + +```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; + }); +}); +``` + +- Register the server before its `listen()`: create it with `createServer()` or + `createSecureServer()` (or call `callback(server)`) first. `shutdown()` knows only the `listen()` + of a registered server: one in progress then (e.g. while the address of a host name is looked + up) is closed once it listens, or cancelled once `timeout` has passed. +- 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. + +A reply of the framework to an error (the default error reply, the `503` of a deadline, the empty +`500` of a failed error handler) closes the connection (`Connection: close`) when the body of the +request has not been received in full when it is sent (`request.complete` is `false`), whether a +part of it has been read or none: binden never reads a body that nobody wants, and the rest of it +would otherwise be read as the next request of the connection. The rule is conservative: a request +refused before Node.js has parsed its whole body loses its connection, even for a small `POST` +whose body came with its headers but is not parsed yet when a middleware refuses it synchronously +(e.g. a `401`, `403` or `415`). The client (or the proxy) then opens a new connection for its next +request. A body received in full, read or not, keeps the connection open. + +### 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. With +`zstd: true`, a request has at most 1024 `zstd` frames (data, empty and skippable ones, over all its +`zstd` codings), whatever its size and the version of Node.js: each frame is a step of the decoding, +and the next one gets `413` with the code `zstd_frames`. Its decoders are charged at most 64 MiB: +each frame but an empty one has a decoder, which is charged its window (or its declared content +size when smaller) and at least 256 KiB, so that a request has at most 256 decoders (8 of the +largest window, 8 MiB), and the next one gets the same `413`. + +```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. +- A copy writes into a new file that it creates itself, and a copy that fails removes only that + file, while `target` is still it. Persist into a directory that only the application writes to: + the check and the removal cannot be atomic against another process that renames files there. A + copy that cannot read the identity of the file that it has created leaves it, empty. +- Await the parse before the reply: the files are stored until the cleanup of the request (once + the response is closed), and a file that comes after it is refused (`run()` rejects with a + `500`). +- `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, a file that is + not inside the root, or anything but a regular file (a socket, a device or a FIFO, which is not + opened) 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. + +Conditional requests and ranges apply to the representation that is sent: a compressed one has a +weak `ETag` and no ranges. A client that updates a resource with a strong `If-Match` (e.g. the +`validator` of `sendFile()`) or resumes a download with `Range` gets them with +`Accept-Encoding: identity` (archives and media, which are downloaded in ranges, are not compressed +by default anyway). + +The decision is taken once per response, as soon as the reply starts (`send()`, `sendFile()`, or +a first write): set its status, its `Content-Type`, `Cache-Control: no-transform` or +`skipCompression()` before. A compressed response loses the `Content-Digest`, `Repr-Digest` and +`Digest` of the uncompressed body, which it no longer matches: a response that must carry a +digest is sent with `skipCompression()` (or compressed ahead of time, with the digest of the +compressed bytes and its `Content-Encoding`). + +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)`, and is + woken while it waits for its next event by a signal that is aborted on `SIGTERM` (it would wait + otherwise until the drain timeout aborts `context.signal`); +- 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, + shutdown, +} 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), +}); + +// Aborted on `SIGTERM`: it wakes the streams that wait for their next event +const closing = new AbortController(); + +const events = async (context: Context): Promise => { + const { response } = context; + // The end of the request, or of the server + const signal = AbortSignal.any([context.signal, closing.signal]); + 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); + +process.once("SIGTERM", () => { + closing.abort(); + shutdown(server).catch((error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }); +}); +``` + +## 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. +- **TypeScript**: `@types/node` 24, or `@types/node` 26 with `skipLibCheck: true` (the types of + `thread-stream`, used by `pino`, do not compile against it: see the + [known issues](https://github.com/binden-js/binden#known-issues)). +- **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/README.md b/README.md index 4ca9035b..fe00b9a7 100644 --- a/README.md +++ b/README.md @@ -1,613 +1,290 @@ -# 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) [![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 server framework (written in [TypeScript](https://www.typescriptlang.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. -## Installation +| 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 | -```bash -npm install binden -``` - -## Usage - -### Binden - -- `.use()` - Add a `Middleware/Router` to the stack - -```typescript -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 - -```typescript -import { Binden } from "binden"; - -const app = new Binden() - .use("/path", middleware1) - .use(middleware2) - .off("/path", middleware1); -``` - -- `.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"; - -const app = new Binden().use("/path", middleware).use("/path2", router); -const secureServer = app.createSecureServer({ key, cert }); -``` - -### Context - -- `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/logger)) - -```typescript -const { log } = context; -log.info("Hello World", { data: 100 }); -``` - -- `.setHeader()` - Set a response header - -```typescript -const name = "X-HEADER"; -const value = ["value1", "value2"]; -context.setHeader(name, value); -``` - -- `.status()` - set the response status - -```typescript -context.status(401); -``` - -- `.request` - get the original request object (`instanceof BindenRequest`) - -```typescript -const { request } = context; -``` - -- `.response` - get the original response object (`instanceof BindenResponse`) - -```typescript -const { response } = context; -``` - -- `.id` - get `id` of the context (generated by the [randomUUID()](https://nodejs.org/dist/latest/docs/api/crypto.html#crypto_crypto_randomuuid_options) function and logged as `trace_id` by the `context.log`) - -```typescript -const { id } = context; -``` - -- `.done` - -```typescript -class MyMiddleware extends Middleware { - public run(context: Context): void { - context.done = true; // Stop passing the `context` to other middlewares - } -} -``` - -or with a function - -```typescript -const MyMiddleware = (context): void => { - context.done = true; -}; -``` - -- `.url` - parsed `URL` object - -```typescript -const { - log, - url: { search }, -} = context; -log.trace("url search string", { search }); -``` - -- `.send()` - execute `context.response.send()` and set `context.done` to `true` - -```typescript -await context.send(data); -// or -await context.response.send(data); -context.done = true; -``` - -- `.json()` - execute `context.response.json()` and set `context.done` to `true` - -```typescript -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](https://github.com/fastify/fast-json-stringify-stringify)) can pan passed as the second argument. - -```typescript -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` - -```typescript -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` - -```typescript -const 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` - -```typescript -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` - -```typescript -const path = ""; -await context.sendFile(path); -// or -await context.response.sendFile(path); -context.done = true; -``` - -- `.throw()` - throw `BindenError` - -```typescript -context.throw(402, { json: { error: "Payment Required" }, expose: true }); -``` - -### Middleware - -Any middleware should be extended from the abstract `Middleware` class and implement the `.run()` method - -```typescript -import { randomInt } from "crypto"; -import { Middleware, Context } from "binden"; - -export class MyMiddleware extends Middleware { - public async run(context: Context): Promise { - 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 - -```typescript -import { Middleware, Context } from "binden"; - -export class MyMiddleware1 extends Middleware { - public run(context: Context): Promise { - return context.json({ message: "Hello World" }); - } -} - -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, - ); - } -} -``` - -- `.ignore_errors` - ignore errors from `await this.run(context)` - -```typescript -import { Middleware, Context } from "binden"; - -export class MyMiddleware1 extends Middleware { - public run(context: Context): Promise { - 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 { - // Throw errors from `mm1.run()` every minute - setInterval(() => { - mm1.ignore_errors = !mm1.ignore_errors; - }, 1000 * 60); - } -} -``` - -### BindenError - -`BindenError` represents an HTTP error - -```typescript -import { Middleware, Context, BindenError } from "binden"; - -export class MyMiddleware extends Middleware { - public run(context: Context): Promise { - 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` }); - } -} -``` - -### BindenRequest - -Simple usage with `http` - -```typescript -import { createServer } from "http"; -import { BindenRequest } from "binden"; -server = createServer({ IncomingMessage: BindenRequest }); -``` - -- `.header()` - get a header by name +## Documentation -```typescript -const rawHeader = request.header("X-Header"); -``` - -- `.id` - get the request id - -```typescript -const { id } = request; -``` - -- `.protocol` - respects the [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) header: - -```typescript -const { protocol } = request; -if (protocol !== "https:") { - console.error("The connection is not secure"); -} -``` - -- `.secure` - same as `request.protocol === "https:"` - -```typescript -const { secure } = request; -if (!secure) { - console.error("The connection is not secure"); -} -``` - -- `.query` - A copy of `URL.search` parsed by the `querystring.parse()` method - -```typescript -const { query } = request; -// same as -const query = { ...parse(this.URL.search.substring(1)) }; -``` - -### BindenResponse - -Simple usage with `http` - -```typescript -import { createServer } from "http"; -import { BindenResponse } from "binden"; -server = createServer({ ServerResponse: BindenResponse }); -``` - -- `.cookies` - The `.send()` method will add cookies to the response - -```typescript -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 - -```typescript -await response.status(400).send(); -``` - -- `.set()` - Set the headers - -```typescript -const headers = { - "X-AMOUNT": "100.02 USD", - "X-MONTHS": ["jan", "feb"], -}; -await response.status(402).set(headers).send("Payment is required"); -``` - -- `.send()` - send data - -```typescript -await response.send( - "Could be `number` | `string` | `Buffer` | `Readable` | `bigint` | `undefined`", -); -``` - -- `.json()` - send an object as `application/json` using `JSON.stringify()` - -```typescript -const json = { k: "v", k1: 1, m: "message", f: false }; -await response.json(json); -``` - -or using a custom `stringify` function - -```typescript -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 `text/plain` - -```typescript -const text = "Hello World!"; -await response.text(text); -``` - -- `.html()` - send text as `text/html` - -```typescript -const html = ""; -await response.html(html); -``` - -- `.form()` - send `URLSearchParams`; +- [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). -```typescript -const form = new URLSearchParams({ a: "1", b: ["a", "c"] }); -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) - - - [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - - - [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) - -```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 - -- [Accept-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding) - -```typescript -import { AcceptEncoding } from "binden"; - -const encodings = AcceptEncoding.fromString(request.headers["accept-encoding"]); -// or using BindenRequest -const { accept_encoding } = request; -``` - -- [Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) - -```typescript -import { Authorization } from "binden"; - -const authorization = AcceptEncoding.fromString( - request.headers["Authorization"], -); -// or using BindenRequest -const { authorization } = request; -``` - -- [Content-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) - -```typescript -import { ContentEncoding } from "binden"; - -const encodings = ContentEncoding.fromString( - request.headers["content-encoding"], -); -// or using BindenRequest -const { content_encoding } = request; -``` - -- [Content-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) - -```typescript -import { ContentRange } from "binden"; +## Development -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) - -```typescript -import { ContentType } from "binden"; - -const type = ContentType.fromString(request.headers["content-type"]); -// or using BindenRequest -const { content_type } = request; -``` - -- [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) - -```typescript -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); -``` - -- [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) - -```typescript -import { Forwarded } from "binden"; - -const forwarded = Forwarded.fromString(request.headers["forwarded"]); -// or using BindenRequest -const { forwarded } = request; -``` +The repository requires Node.js `>=24.14.1` and npm `>=11.11.0` (its own `engines`). The published +packages require only Node.js `>=24.14.1`: they declare no npm version, and any npm that supports +that Node.js installs them. -- [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) - -```typescript -import { IfModifiedSince } from "binden"; - -const if_modified_since = IfModifiedSince.fromString( - request.headers["if-modified-since"], -); -// or using BindenRequest -const { if_modified_since } = request; -``` - -- [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - -```typescript -import { Range } from "binden"; - -const range = Range.fromString(request.headers.range); -// or using BindenRequest -const { range } = request; -``` +```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 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 (in the `package` job: from the commit, with the next +version and its `gitHead`), the very archives that the release job publishes, without packing them +again: -### Test +```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. + +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. + +## 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). + +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 +(`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 + +- **`@types/node` 26 requires `skipLibCheck`.** The supported TypeScript setups are `@types/node` + 24, type checked strictly, and `@types/node` 26 with `skipLibCheck: true`. 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, 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 in both setups. +- **Node.js sends the head of a queued response before its early hints.** For a pipelined request + whose response waits behind the previous one, Node.js 24 and 26 put the head of the response + before an informational response (`writeEarlyHints()`, …) when the first chunk of its body is a + `Buffer`, or a string in an encoding other than exactly `utf8` or `latin1` (e.g. `utf-8`, `hex` + or `base64`), so that the client reads the `103` as the body (not reported upstream yet). binden + works around it by committing the head with an empty `latin1` write before that chunk, which it + then writes as it is (see the [README of binden](packages/binden/README.md)); a server that + writes such responses without binden is affected. + +## 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)), by two jobs of the CI workflow, +both on Node.js 24.16.0 with its npm 11.13.0 (both checked). The archives are packed once, by a job +that cannot publish, and the job that publishes them never builds or packs anything: the packages +that npm receives are the bytes that were checked. + +- The `package` job has no right to publish (`contents: read` only: no npm token, no OIDC). It + packs the archives of the next release, checks them, and uploads them with their manifest as an + artifact of the run; the digest of the manifest is an output of the job. +- The `release` job runs after the tests and the `package` job, in the `release` environment (the + one that the npm trusted publisher of the packages expects, which admits `main` only and waits + for the approval of a maintainer: see [Repository settings](#repository-settings)), one + publication at a time (with the `recover` job). It installs only the release tooling + (`npm ci --ignore-scripts`), downloads the artifact of the `package` job of the same run (by its + id: never an artifact of another run), checks the digest of its manifest, and publishes these + archives. + +1. `package`: the next version is derived from [Conventional Commits](https://conventionalcommits.org) + since the last `v*` tag (semantic-release in a local clone, as `npm run release:rehearsal` does), + 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. `package`: `scripts/release.mjs version` sets that version in **every** workspace (and the + internal dependency ranges to `^`), with the commit as `gitHead`, then `pack` packs + every workspace once, checks the `package.json` (byte for byte the one that `version` wrote), + the name, the version, the `gitHead` and the files + ([`scripts/pack-files.json`](scripts/pack-files.json) and the rules of + [`scripts/pack-rules.mjs`](scripts/pack-rules.mjs)) inside each archive, and lists the archives + (name, version, commit, `sha512` integrity, file) in `release-manifest.json`. Every check reads + an archive in memory ([`scripts/tarballs.mjs`](scripts/tarballs.mjs): nothing is extracted or + run), strictly (complete headers, data and end of archive, checksums, octal sizes, only the pax + records that npm writes, regular files and directories at normalized paths, each once), and with + the `tar` of the npm that publishes it, which must read the same entries, types and bytes; +3. `package`: `scripts/pack-check.mjs --tarballs` and `scripts/consumer-check.mjs --tarballs` check + these archives, which are uploaded with their manifest; +4. `release`: semantic-release computes the next version again. Its `prepareCmd` runs + `scripts/release.mjs version` (the manifests and the lockfile: nothing is built or packed), then + `publish --dry-run` of the downloaded archives: there must be one archive of every workspace, of + that version, packed from `HEAD`, with the integrity of the manifest, byte for byte the + `package.json` that `version` wrote into the workspace (what npm installs: scripts, + dependencies, entry points), the expected files and none that the rules refuse, none may be + published yet, and `npm publish --dry-run` must accept each one. Any difference (a version or a + commit that is not the one of the archives, an archive that changed or was substituted) fails + the release **before anything is tagged**; +5. the `v` tag is pushed; +6. `scripts/release.mjs publish` checks the archives again, then the registry for **all** the + packages, and only then publishes these archives (`npm publish `, with provenance) in + dependency order; +7. 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)). + +When the `release` job fails before the tag because the archives are not the ones of the release, +re-run all the jobs of the workflow, so that the `package` job packs them again: re-running the +failed job alone checks the same archives. + +The npm that publishes is the npm that runs the scripts: the one of `npm run` or `npm exec` (which +set `npm_execpath`), otherwise the `npm` that a shell runs, the first regular executable file of +the `PATH`, by its real path (on Windows, the npm that comes with Node.js). Its `tar` is loaded from +that same installation ([`scripts/npm.mjs`](scripts/npm.mjs)). When the `npm` of the `PATH` is not +an npm installation (e.g. a version-manager shim, not a link to the CLI of npm), the scripts refuse +to run it, and with any npm that is not one, the scripts that read archives refuse to run +(`scripts/release.mjs pack` and `publish` before anything else) instead of reading them with the +`tar` of another npm, such as the one that comes with Node.js: run them through `npm run` or +`npm exec` then. + +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 checkouts of the release jobs. 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. Every pull request rehearses +the release in the `package` job (`npm run release:rehearsal`): semantic-release computes the next +version and its notes in a clone, where `scripts/release.mjs` runs `verify`, `version` and `pack`, +then, in another clone of the commit as the `release` job does, `version` and `publish --dry-run` +(the checks of the archives, 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 from `main` (Actions → CI → Run workflow, "Use workflow from" `main`) with its +version. Two jobs recover it, the first one without any right to publish, and both run only for a +dispatch from `main`. Dispatched from another branch, they are skipped; as such a run uses the +workflow of that branch (where these conditions may be gone), the `release` environment, which +admits `main` only, is what keeps it from publishing: + +1. `recover-package` 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`; it then checks out that commit, packs the archives again with `scripts/release.mjs version` and + `pack` on the runtime of the release (the same archives, bit for bit, as the `package` job made), + checks them, and uploads them as an artifact of the run; +2. `recover` checks the version, the tag and its commit again, checks out that commit, downloads the + artifact of the `recover-package` job of the same run and checks the digest of its manifest, sets + the version (`scripts/release.mjs version`: nothing is installed, built or packed), and publishes + with `scripts/release.mjs publish --skip-existing` once a maintainer approved the job (the + `release` environment): the archives must be the ones of the commit of the tag, with the + `package.json` that `version` wrote, 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. `recover` then creates the GitHub Release when the GitHub API answers 404 for it (any other + answer fails the job), with notes generated by GitHub. + +A recovery runs in the concurrency group of the runs of `main` (`ci-refs/heads/main`), and its +`recover` job in the `publish` group of the `release` job. With the default queue of GitHub +(`queue: single`), a group keeps at most one pending run (or job), and a newly queued one cancels it +([concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency)): +a recovery dispatched while a run of `main` is in progress waits for it, and is cancelled if a +commit is pushed to `main` meanwhile; dispatching a recovery likewise cancels a run of `main` that +was waiting (its commits are then released by the run of the next push to `main`). Check that the +recovery ran to the end, and dispatch it again otherwise. + +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 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 -npm run test:ci -``` +git checkout v +npm ci +node scripts/release.mjs version +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 +into this repository. + +### Repository settings + +The releases rely on settings of GitHub and npm that are not in the repository. Check them before +the first release, and after any change of the release jobs: + +- **The `release` environment** (Settings → Environments): its deployment branches and tags + limited to "Selected branches and tags" with the single rule `main` (a branch, no tag), required + reviewers (the maintainers), and no bypass of these rules for administrators. The `release` and + `recover` jobs wait for the approval of a reviewer. A run dispatched from another branch uses the + workflow of that branch, whose conditions may be gone: this environment keeps it from + publishing. +- **npm trusted publishing** for each of `binden`, `@binden/logger`, `@binden/body`, + `@binden/compression` and `@binden/multipart` (the settings of the package on npm): the + repository `binden-js/binden`, the workflow `ci.yml` and the environment `release` (without it, + any job of `ci.yml`, on any branch, could publish). Once it works, no `NPM_TOKEN` is needed; until + then, the token is a secret of the `release` environment, never of the repository (which the + workflow of any branch can read). +- **The protection of `main`** (a ruleset or a branch protection rule, for administrators too): + changes through pull requests only, approved by a code owner, without force pushes or deletion, + and with the required status checks `lint`, `package`, `test (24.14.1)`, `test (24)`, + `test (26)`, `windows`, `macos`, `dependency-review` and `Analyze (javascript-typescript)` + (CodeQL). `package` rehearses the release on its runtime: without it, a change whose archives the + `release` job would refuse could be merged. +- **The former repositories** of the packages (`binden-js/logger`, `binden-js/body`, + `binden-js/compression` and `binden-js/multipart`): archived, their release workflows disabled + and their npm tokens revoked, so that only this workflow publishes the packages. +- **Security** (the security settings of the repository): private vulnerability reporting (see + [SECURITY.md](SECURITY.md)), secret scanning and push protection. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..a588e6d8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security policy + +## Supported versions + +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, 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. 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/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/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e2ac6616..00000000 --- a/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/docs/assets/hierarchy.js b/docs/assets/hierarchy.js deleted file mode 100644 index a3e62523..00000000 --- a/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/docs/assets/highlight.css b/docs/assets/highlight.css deleted file mode 100644 index 2191e11e..00000000 --- a/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/docs/assets/icons.js b/docs/assets/icons.js deleted file mode 100644 index 4d97ec0c..00000000 --- a/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/docs/assets/icons.svg b/docs/assets/icons.svg deleted file mode 100644 index 50ad5799..00000000 --- a/docs/assets/icons.svg +++ /dev/null @@ -1 +0,0 @@ -MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file diff --git a/docs/assets/main.js b/docs/assets/main.js deleted file mode 100644 index d639564f..00000000 --- a/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/docs/assets/navigation.js b/docs/assets/navigation.js deleted file mode 100644 index 85e45b7a..00000000 --- a/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/docs/assets/search.js b/docs/assets/search.js deleted file mode 100644 index 79d94608..00000000 --- a/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/docs/assets/style.css b/docs/assets/style.css deleted file mode 100644 index 02473c22..00000000 --- a/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/docs/classes/AcceptEncoding.html b/docs/classes/AcceptEncoding.html deleted file mode 100644 index 87fc1fc6..00000000 --- a/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/docs/classes/Authorization.html b/docs/classes/Authorization.html deleted file mode 100644 index 47e20466..00000000 --- a/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/docs/classes/Binden.html b/docs/classes/Binden.html deleted file mode 100644 index 9f47799b..00000000 --- a/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/docs/classes/BindenError.html b/docs/classes/BindenError.html deleted file mode 100644 index 9a1997ba..00000000 --- a/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/docs/classes/BindenRequest.html b/docs/classes/BindenRequest.html deleted file mode 100644 index f92c47cf..00000000 --- a/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/docs/classes/BindenResponse.html b/docs/classes/BindenResponse.html deleted file mode 100644 index b5c8323b..00000000 --- a/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/docs/classes/ContentEncoding.html b/docs/classes/ContentEncoding.html deleted file mode 100644 index c950fd0a..00000000 --- a/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/docs/classes/ContentRange.html b/docs/classes/ContentRange.html deleted file mode 100644 index 3ebd2daa..00000000 --- a/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/docs/classes/ContentType.html b/docs/classes/ContentType.html deleted file mode 100644 index 3f777671..00000000 --- a/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/docs/classes/Context.html b/docs/classes/Context.html deleted file mode 100644 index 6986cb5a..00000000 --- a/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/docs/classes/Cookie.html b/docs/classes/Cookie.html deleted file mode 100644 index 09638d70..00000000 --- a/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/docs/classes/Forwarded.html b/docs/classes/Forwarded.html deleted file mode 100644 index 31aee04c..00000000 --- a/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/docs/classes/IfModifiedSince.html b/docs/classes/IfModifiedSince.html deleted file mode 100644 index 8850632a..00000000 --- a/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/docs/classes/Middleware.html b/docs/classes/Middleware.html deleted file mode 100644 index 26b1cfe3..00000000 --- a/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/docs/classes/Range.html b/docs/classes/Range.html deleted file mode 100644 index 6521dcad..00000000 --- a/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/docs/classes/Router.html b/docs/classes/Router.html deleted file mode 100644 index c06c5d9c..00000000 --- a/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/docs/hierarchy.html b/docs/hierarchy.html deleted file mode 100644 index 8193e334..00000000 --- a/docs/hierarchy.html +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-

binden

-

Hierarchy Summary

- - - - - - - - - -
-
- - -
-
-
-
- - diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 31d1e79d..00000000 --- a/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/docs/interfaces/IAcceptEncoding.html b/docs/interfaces/IAcceptEncoding.html deleted file mode 100644 index 8f7eed2c..00000000 --- a/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/docs/interfaces/IAuthorization.html b/docs/interfaces/IAuthorization.html deleted file mode 100644 index 543f28ce..00000000 --- a/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/docs/interfaces/IBaseContext.html b/docs/interfaces/IBaseContext.html deleted file mode 100644 index 247b6e33..00000000 --- a/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/docs/interfaces/IBindenErrorOptions.html b/docs/interfaces/IBindenErrorOptions.html deleted file mode 100644 index 444559d3..00000000 --- a/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/docs/interfaces/IBindenOptions.html b/docs/interfaces/IBindenOptions.html deleted file mode 100644 index 80e2dcbb..00000000 --- a/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/docs/interfaces/IContentEncoding.html b/docs/interfaces/IContentEncoding.html deleted file mode 100644 index ef351f9c..00000000 --- a/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/docs/interfaces/IContentRange.html b/docs/interfaces/IContentRange.html deleted file mode 100644 index 915c859c..00000000 --- a/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/docs/interfaces/IContentType.html b/docs/interfaces/IContentType.html deleted file mode 100644 index 053ad465..00000000 --- a/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/docs/interfaces/ICookie.html b/docs/interfaces/ICookie.html deleted file mode 100644 index df460711..00000000 --- a/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/docs/interfaces/IForwarded.html b/docs/interfaces/IForwarded.html deleted file mode 100644 index a0d586be..00000000 --- a/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/docs/interfaces/IIfModifiedSince.html b/docs/interfaces/IIfModifiedSince.html deleted file mode 100644 index 34797b90..00000000 --- a/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/docs/interfaces/IMiddlewareParams.html b/docs/interfaces/IMiddlewareParams.html deleted file mode 100644 index 8cb10d2e..00000000 --- a/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/docs/interfaces/IRange.html b/docs/interfaces/IRange.html deleted file mode 100644 index 09874526..00000000 --- a/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/docs/interfaces/IRouterOptions.html b/docs/interfaces/IRouterOptions.html deleted file mode 100644 index 5e5deb6c..00000000 --- a/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/docs/modules.html b/docs/modules.html deleted file mode 100644 index aac2e2b5..00000000 --- a/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/docs/types/IAcceptEncodings.html b/docs/types/IAcceptEncodings.html deleted file mode 100644 index 1d938be3..00000000 --- a/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/docs/types/IAuthenticationType.html b/docs/types/IAuthenticationType.html deleted file mode 100644 index 9ce54b5e..00000000 --- a/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/docs/types/IEncodings.html b/docs/types/IEncodings.html deleted file mode 100644 index 048c0654..00000000 --- a/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/docs/types/IFunctionMiddleware.html b/docs/types/IFunctionMiddleware.html deleted file mode 100644 index c08d20e1..00000000 --- a/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/docs/types/IHeaders.html b/docs/types/IHeaders.html deleted file mode 100644 index cdc60c7d..00000000 --- a/docs/types/IHeaders.html +++ /dev/null @@ -1,235 +0,0 @@ - - - - - - IHeaders | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IHeaders

-
-
- IHeaders: - Record<string, - IHeadersValue> -
- -
-
- - -
-
-
-
- - diff --git a/docs/types/IHeadersValue.html b/docs/types/IHeadersValue.html deleted file mode 100644 index 162ebe6e..00000000 --- a/docs/types/IHeadersValue.html +++ /dev/null @@ -1,233 +0,0 @@ - - - - - - IHeadersValue | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IHeadersValue

-
-
- IHeadersValue: - number - | - string - | - readonly - string[] -
- -
-
- - -
-
-
-
- - diff --git a/docs/types/IMiddleware.html b/docs/types/IMiddleware.html deleted file mode 100644 index 44935b83..00000000 --- a/docs/types/IMiddleware.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - IMiddleware | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IMiddleware

-
-
- IMiddleware: - IFunctionMiddleware - | - Middleware -
- -
-
- - -
-
-
-
- - diff --git a/docs/types/IProtocol.html b/docs/types/IProtocol.html deleted file mode 100644 index 2d36047f..00000000 --- a/docs/types/IProtocol.html +++ /dev/null @@ -1,229 +0,0 @@ - - - - - - IProtocol | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IProtocol

-
-
- IProtocol: - "http:" - | - "https:" -
- -
-
- - -
-
-
-
- - diff --git a/docs/types/IStack.html b/docs/types/IStack.html deleted file mode 100644 index b404f3a5..00000000 --- a/docs/types/IStack.html +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - IStack | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IStack

-
-
- IStack: - [IStackItem[], - RegExp - | - string - | - null] -
- -
-
- - -
-
-
-
- - diff --git a/docs/types/IStackItem.html b/docs/types/IStackItem.html deleted file mode 100644 index f1ea4db8..00000000 --- a/docs/types/IStackItem.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - IStackItem | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IStackItem

-
-
- IStackItem: - IMiddleware - | - Router -
- -
-
- - -
-
-
-
- - diff --git a/docs/types/IUnit.html b/docs/types/IUnit.html deleted file mode 100644 index 3b7386b6..00000000 --- a/docs/types/IUnit.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - IUnit | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IUnit

-
-
- IUnit: - "bytes" -
- -
-
- - -
-
-
-
- - diff --git a/docs/variables/DefaultErrorCode.html b/docs/variables/DefaultErrorCode.html deleted file mode 100644 index 2112f5e1..00000000 --- a/docs/variables/DefaultErrorCode.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - DefaultErrorCode | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable DefaultErrorCodeConst

-
-
- DefaultErrorCode: - 500 -
- -
-
- - -
-
-
-
- - diff --git a/docs/variables/ct_form.html b/docs/variables/ct_form.html deleted file mode 100644 index 4936259e..00000000 --- a/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/docs/variables/ct_html.html b/docs/variables/ct_html.html deleted file mode 100644 index 3e8265cf..00000000 --- a/docs/variables/ct_html.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - ct_html | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable ct_htmlConst

-
-
- ct_html: - "text/html" -
- -
-
- - -
-
-
-
- - diff --git a/docs/variables/ct_json.html b/docs/variables/ct_json.html deleted file mode 100644 index 12c30098..00000000 --- a/docs/variables/ct_json.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - ct_json | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable ct_jsonConst

-
-
- ct_json: - "application/json" -
- -
-
- - -
-
-
-
- - diff --git a/docs/variables/ct_text.html b/docs/variables/ct_text.html deleted file mode 100644 index 11fab463..00000000 --- a/docs/variables/ct_text.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - ct_text | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable ct_textConst

-
-
- ct_text: - "plain/text" -
- -
-
- - -
-
-
-
- - diff --git a/eslint.config.js b/eslint.config.js index 27d6d4d6..471dbaa6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,262 @@ -import config from "eslint-config-binden-ts"; +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 [ - ...config, - { languageOptions: { parserOptions: { project: "tsconfig.json" } } }, + { + 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", + }, + }, ]; 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..bd6a513b --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/index.ts b/index.ts deleted file mode 100644 index 66e5496a..00000000 --- a/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export * from "./src/headers/accept-encoding.js"; -export * from "./src/headers/authorization.js"; -export * from "./src/headers/content-encoding.js"; -export * from "./src/headers/content-range.js"; -export * from "./src/headers/content-type.js"; -export * from "./src/headers/cookie.js"; -export * from "./src/headers/forwarded.js"; -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 * from "./src/binden.js"; -export * from "./src/middleware.js"; -export * from "./src/request.js"; -export * from "./src/response.js"; -export * from "./src/router.js"; diff --git a/package-lock.json b/package-lock.json index f2ba5ec9..0921c52a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,41 +1,40 @@ { - "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/exec": "^7.1.0", + "@types/node": "^24.19.2", + "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.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.14.1", + "npm": ">=11.11.0" } }, "node_modules/@actions/core": { @@ -44,7 +43,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 +54,6 @@ "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@actions/io": "^3.0.2" } @@ -67,19 +64,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.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18.17" } @@ -89,18 +84,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 +102,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 +121,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,1393 +168,1324 @@ "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==", + "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", - "peer": true, "dependencies": { - "chalk": "^4.1.0" + "conventional-commits-parser": "^7.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, - "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==", + "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": { - "@jridgewell/trace-mapping": "0.3.9" + "@simple-libs/child-process-utils": "^2.0.0", + "@simple-libs/stream-utils": "^2.0.0", + "verkit": "^0.5.0" }, "engines": { - "node": ">=12" + "node": ">=22" + }, + "peerDependencies": { + "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/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/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", - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=22" } }, - "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/@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", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "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/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": { - "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": [ + "android" + ], "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-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, - "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-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": ">=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/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", - "peer": true, + "optional": true, + "os": [ + "darwin" + ], "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-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": { - "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/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": "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": [ + "freebsd" + ], + "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-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, - "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/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": "Apache-2.0", - "peer": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "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", - "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==", + "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": "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-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-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-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", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "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-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": { - "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-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-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-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", - "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-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": ">=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-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", + "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==", - "dev": true, - "license": "MIT" - }, - "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" - } - }, - "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==", - "dev": true, - "license": "MIT", - "optional": true, + "@types/json-schema": "^7.0.15" + }, "engines": { - "node": ">=14" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "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_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", - "peer": true, "engines": { - "node": ">=12.22.0" + "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/@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/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, - "dependencies": { - "graceful-fs": "4.2.10" - }, + "license": "Apache-2.0", "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/@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": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "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/@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": { + "dequal": "^2.0.3" + } }, - "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/@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": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "fs-extra": "^11.0.0", - "lodash": "^4.17.4" - }, - "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "semantic-release": ">=18.0.0" + "@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/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/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": { - "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/types": "^0.15.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/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": "ISC", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "compare-func": "^2.0.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.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/@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": "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": ">=18.18.0" } }, - "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/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, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.22" }, "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/@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": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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", - "peer": true, - "dependencies": { - "escape-string-regexp": "5.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "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-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, - "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/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", - "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/types": "^17.0.0", + "bottleneck": "^2.15.3" }, "engines": { - "node": "^18.19.0 || >=20.5.0" + "node": ">= 20" }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "peerDependencies": { + "@octokit/core": "^7.0.0" } }, - "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/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, "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@octokit/openapi-types": "^28.0.0" } }, - "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/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": "Apache-2.0", - "peer": true, + "license": "MIT", + "dependencies": { + "@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.18.0" + "node": ">= 20" } }, - "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/@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, - "engines": { - "node": ">=12" + "dependencies": { + "@octokit/types": "^18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 20" } }, - "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/@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": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@octokit/openapi-types": "^29.0.1" } }, - "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/@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": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12.22.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/@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", - "peer": true, "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" + "graceful-fs": "4.2.10" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12.22.0" } }, - "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==", + "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, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "ISC" }, - "node_modules/@semantic-release/npm/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/@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, - "engines": { - "node": ">=18" + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=12" } }, - "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/@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", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "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==", + "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": { "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" + "micromatch": "^4.0.2" }, "engines": { "node": ">=20.8.1" @@ -1604,13 +1494,25 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-changelog-angular": { + "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", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "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" }, @@ -1618,13 +1520,22 @@ "node": ">=18" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-commits-parser": { + "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", - "peer": true, "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" @@ -1636,81 +1547,242 @@ "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==", + "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==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "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==", + "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": "ISC", - "peer": true, + "license": "MIT", "dependencies": { - "lru-cache": "^10.0.1" + "@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" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=24.1.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/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", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "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/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.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", + "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", + "read-package-up": "^11.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "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", - "peer": true, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "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": "BSD-2-Clause", - "peer": true, + "license": "ISC", "dependencies": { - "hosted-git-info": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" + "compare-func": "^2.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=18" } }, - "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==", + "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": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" }, "engines": { "node": ">=18" + } + }, + "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", + "dependencies": { + "lru-cache": "^10.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "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": "ISC" + }, + "node_modules/@semantic-release/release-notes-generator/node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" } }, "node_modules/@semantic-release/release-notes-generator/node_modules/read-package-up": { @@ -1719,7 +1791,6 @@ "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", @@ -1738,7 +1809,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 +1823,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 +1885,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 +1920,6 @@ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -1864,7 +1933,6 @@ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1877,27 +1945,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": "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/@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": "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": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -1906,79 +1976,38 @@ "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 +2026,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 +2042,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": "22.0.0", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-TDbVpbccc2HfiqHR09Argj3mHV1KMW7sCCKj52fsl8lbRLkEn7fB1966EWhOKWUBcqfBueZuPoA7/OK1CKiy3g==", "dev": true, "license": "MIT", "dependencies": { @@ -2045,9 +2055,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 +2069,252 @@ "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/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==", + "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": "BSD-2-Clause", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "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": "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==", + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "dev": true, - "license": "ISC", - "peer": true + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } }, "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 +2330,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 +2396,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 +2413,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 +2443,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,56 +2452,71 @@ "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", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "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/before-after-hook": { @@ -2560,40 +2524,30 @@ "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 +2563,46 @@ "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==", + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "dev": true, - "license": "ISC" + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "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 +2615,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 +2633,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 +2676,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 +2702,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 +2718,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 +2769,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 +2782,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 +2806,6 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=10" } @@ -2908,7 +2816,6 @@ "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "string-width": "^4.2.0" }, @@ -2919,104 +2826,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 +2902,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 +2977,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 +2991,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 +3037,6 @@ "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3209,21 +3056,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 +3086,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 +3143,6 @@ "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "type-fest": "^1.0.1" }, @@ -3304,7 +3159,6 @@ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -3312,17 +3166,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 +3177,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 +3194,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 +3209,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 +3222,9 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "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": { @@ -3456,27 +3244,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 +3263,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 +3300,6 @@ "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" @@ -3552,7 +3314,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 +3338,6 @@ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" }, @@ -3591,7 +3351,6 @@ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.17.0" } @@ -3602,7 +3361,6 @@ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -3610,27 +3368,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 +3384,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 +3397,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 +3416,6 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3717,7 +3426,6 @@ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3726,20 +3434,74 @@ } }, "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/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", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "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": ">=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.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "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": { @@ -3747,164 +3509,115 @@ } }, "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==", + "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": ">=10" + "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 +3629,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 +3686,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -3977,7 +3699,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -3988,103 +3709,98 @@ "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==", + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "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" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">=6" } }, - "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==", + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "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", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "engines": { + "node": ">=0.8.x" + } }, - "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==", + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "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" + "@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": ">=8.6.0" + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "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==", + "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, - "license": "ISC", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "pure-rand": "^8.0.0" }, "engines": { - "node": ">= 6" + "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", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "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 +3823,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 +3844,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 +3911,6 @@ "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-unicode-supported": "^2.0.0" }, @@ -4197,32 +3921,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 +3967,6 @@ "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -4275,7 +3980,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 +3991,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 +4027,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 +4040,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 +4055,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 +4070,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 +4094,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 +4107,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 +4129,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,461 +4138,93 @@ "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" - } - }, - "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==", - "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==", - "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" - }, - "engines": { - "node": ">=8" - } - }, - "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==", - "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" - }, - "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==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "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==", - "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" - } - }, - "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==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver" - } - }, - "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==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "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==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "readable-stream": "^3.0.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==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": ">=10" - } - }, - "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==", - "dev": true, - "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" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "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==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ini": "^1.3.4" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "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==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "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" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, "node_modules/has-flag": { @@ -4961,28 +4237,17 @@ "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/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": { - "function-bind": "^1.1.2" + "hookified": "^1.15.0" }, "engines": { - "node": ">= 0.4" - } - }, - "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==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" + "node": ">=20" } }, "node_modules/highlight.js": { @@ -4991,7 +4256,6 @@ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": "*" } @@ -5002,7 +4266,6 @@ "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=20" }, @@ -5010,13 +4273,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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" + }, "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==", + "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, "dependencies": { "lru-cache": "^11.1.0" }, @@ -5024,17 +4293,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0", - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5043,44 +4301,43 @@ "license": "MIT" }, "node_modules/http-proxy-agent": { - "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==", + "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", - "peer": true, "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "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==", + "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": { - "agent-base": "^7.1.2", - "debug": "4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "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==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { - "node": ">=10.17.0" + "node": ">=18.18.0" } }, "node_modules/husky": { @@ -5088,6 +4345,7 @@ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, + "license": "MIT", "bin": { "husky": "bin.js" }, @@ -5098,6 +4356,27 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5109,12 +4388,11 @@ } }, "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==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -5132,7 +4410,6 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -5143,7 +4420,6 @@ "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.3.4", "import-meta-resolve": "^4.0.0" @@ -5158,7 +4434,6 @@ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -5170,29 +4445,29 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.19" } }, "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==", + "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, "engines": { - "node": ">=8" - } - }, - "node_modules/index-to-position": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -5200,51 +4475,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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.", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "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": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, "license": "ISC", - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/is-arrayish": { @@ -5252,38 +4497,7 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, - "license": "MIT", - "peer": true - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/is-extglob": { "version": "2.1.1", @@ -5295,6 +4509,16 @@ "node": ">=0.10.0" } }, + "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/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5324,68 +4548,44 @@ "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, "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==", + "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", "engines": { - "node": ">=8" + "node": ">=12" + }, + "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==", + "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": "MIT", - "peer": true, "engines": { - "node": ">=8" + "node": ">=18" }, "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "text-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "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==", + "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": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5396,8 +4596,7 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", @@ -5407,12 +4606,11 @@ "license": "ISC" }, "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==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", @@ -5450,9 +4648,9 @@ } }, "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==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5463,42 +4661,24 @@ "node": ">=8" } }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "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==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6.0" } }, "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "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, "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -5506,14 +4686,23 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "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==", + "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", "dependencies": { "argparse": "^2.0.1" @@ -5522,34 +4711,24 @@ "js-yaml": "bin/js-yaml.js" } }, - "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 + "license": "MIT" }, "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 + "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==", + "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": [ { @@ -5578,24 +4757,21 @@ "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 + "license": "MIT" }, "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==", + "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", - "peer": true + "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5603,61 +4779,14 @@ "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==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT", - "peer": true - }, - "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==", - "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 - }, "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==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", - "peer": true, "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" + "@keyv/serialize": "^1.1.1" } }, "node_modules/levn": { @@ -5666,7 +4795,6 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -5680,13 +4808,12 @@ "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/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==", + "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": [ { @@ -5704,15 +4831,15 @@ } }, "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==", + "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", "dependencies": { - "picomatch": "^4.0.5", + "picomatch": "^4.0.7", "string-argv": "^0.3.2", - "tinyexec": "^1.2.4" + "tinyexec": "^1.3.1" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -5724,20 +4851,7 @@ "url": "https://opencollective.com/lint-staged" }, "optionalDependencies": { - "yaml": "^2.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "yaml": "^2.9.1" } }, "node_modules/load-json-file": { @@ -5746,7 +4860,6 @@ "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", @@ -5763,7 +4876,6 @@ "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" @@ -5788,164 +4900,58 @@ "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 + "license": "MIT" }, "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", - "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 + "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", - "peer": 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", - "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 + "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", - "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 + "license": "MIT" }, - "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==", + "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": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "20 || >=22" } }, - "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", @@ -5953,47 +4959,44 @@ "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==", + "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": { - "semver": "^7.5.3" + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "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==", + "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/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "dev": true, "funding": [ { @@ -6008,8 +5011,8 @@ "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.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" @@ -6024,7 +5027,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6038,7 +5040,6 @@ "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", @@ -6055,50 +5056,21 @@ "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==", + "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": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "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": ">=16.10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6109,18 +5081,7 @@ "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" - } + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -6136,16 +5097,28 @@ "node": ">=8.6" } }, + "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": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", - "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.1.tgz", + "integrity": "sha512-YAApRsTpM2+gnUFHhRNGfkX8NK0V5xj1nyQLufGlwEI+eX4xRsTcel2IXDuMEGSGjBvzykNW4VuoEACgwYe7BQ==", "dev": true, "funding": [ "https://github.com/sponsors/broofa" ], "license": "MIT", - "peer": true, "bin": { "mime": "bin/cli.js" }, @@ -6154,39 +5127,32 @@ } }, "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==", + "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": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", - "peer": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -6195,38 +5161,10 @@ "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", @@ -6237,192 +5175,19 @@ "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": { + "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", @@ -6441,34 +5206,20 @@ "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 + "license": "MIT" }, "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" - } + "license": "MIT" }, "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": [ { @@ -6491,7 +5242,6 @@ "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", @@ -6527,7 +5277,6 @@ "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", @@ -6537,23 +5286,12 @@ "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==", + "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, "engines": { "node": ">=20" }, @@ -6562,9 +5300,9 @@ } }, "node_modules/npm": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.13.0.tgz", - "integrity": "sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg==", + "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", @@ -6634,7 +5372,6 @@ ], "dev": true, "license": "Artistic-2.0", - "peer": true, "workspaces": [ "docs", "smoke-tests", @@ -6644,8 +5381,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.4.3", - "@npmcli/config": "^10.8.1", + "@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", @@ -6663,46 +5400,46 @@ "fs-minipass": "^3.0.3", "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", + "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", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.6", - "libnpmexec": "^10.2.6", - "libnpmfund": "^7.0.20", + "libnpmdiff": "^8.1.13", + "libnpmexec": "^10.3.3", + "libnpmfund": "^7.0.27", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.6", - "libnpmpublish": "^11.1.3", + "libnpmpack": "^9.1.14", + "libnpmpublish": "^11.2.1", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.5", + "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.3.0", + "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.1", + "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.0", + "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.7.4", + "semver": "^7.8.5", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.13", + "tar": "^7.5.22", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -6718,17 +5455,33 @@ } }, "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==", + "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, "license": "MIT", - "peer": true, "dependencies": { - "path-key": "^3.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm/node_modules/@gar/promise-retry": { @@ -6736,7 +5489,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -6746,7 +5498,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "minipass": "^7.0.4" }, @@ -6758,15 +5509,13 @@ "version": "1.1.0", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", + "version": "4.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", @@ -6779,11 +5528,10 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.4.3", + "version": "9.9.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@gar/promise-retry": "^1.0.0", "@isaacs/string-locale-compare": "^1.1.0", @@ -6828,11 +5576,10 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.8.1", + "version": "10.14.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", @@ -6852,7 +5599,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -6865,7 +5611,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", @@ -6885,7 +5630,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" @@ -6902,7 +5646,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", @@ -6918,7 +5661,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", @@ -6935,7 +5677,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -6945,7 +5686,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -6955,7 +5695,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", @@ -6974,7 +5713,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "which": "^6.0.0" }, @@ -6987,7 +5725,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "postcss-selector-parser": "^7.0.0" }, @@ -7000,7 +5737,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -7010,7 +5746,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", @@ -7027,7 +5762,6 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" }, @@ -7036,11 +5770,10 @@ } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.2.0", + "version": "3.2.1", "dev": true, "inBundle": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -7050,7 +5783,6 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -7060,7 +5792,6 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", @@ -7078,7 +5809,6 @@ "dev": true, "inBundle": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" @@ -7088,14 +5818,13 @@ } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.1.0", + "version": "3.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -7107,7 +5836,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": "^16.14.0 || >=18.0.0" } @@ -7117,7 +5845,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" @@ -7131,7 +5858,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -7141,7 +5867,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14" } @@ -7150,32 +5875,28 @@ "version": "2.1.0", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "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/balanced-match": { "version": "4.0.4", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": "18 || 20 || >=22" } }, "node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", + "version": "6.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", @@ -7192,7 +5913,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18.20" }, @@ -7201,16 +5921,15 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.5", + "version": "5.0.9", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/npm/node_modules/cacache": { @@ -7218,7 +5937,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", @@ -7240,7 +5958,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -7253,7 +5970,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": ">=18" } @@ -7269,17 +5985,15 @@ ], "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.4", + "version": "5.0.5", "dev": true, "inBundle": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=20" } @@ -7289,7 +6003,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -7299,7 +6012,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": ">= 18" } @@ -7309,7 +6021,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "bin": { "cssesc": "bin/cssesc" }, @@ -7322,7 +6033,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -7340,7 +6050,6 @@ "dev": true, "inBundle": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.3.1" } @@ -7350,7 +6059,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -7359,15 +6067,13 @@ "version": "3.1.3", "dev": true, "inBundle": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/npm/node_modules/fastest-levenshtein": { "version": "1.0.16", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.9.1" } @@ -7377,7 +6083,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "minipass": "^7.0.3" }, @@ -7390,7 +6095,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -7407,15 +6111,13 @@ "version": "4.2.11", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "lru-cache": "^11.1.0" }, @@ -7427,15 +6129,13 @@ "version": "4.2.0", "dev": true, "inBundle": true, - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/npm/node_modules/http-proxy-agent": { "version": "7.0.2", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -7449,7 +6149,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -7464,7 +6163,6 @@ "inBundle": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -7481,7 +6179,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "minimatch": "^10.0.3" }, @@ -7494,7 +6191,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -7504,7 +6200,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/package-json": "^7.0.0", "npm-package-arg": "^13.0.0", @@ -7518,11 +6213,10 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.1.0", + "version": "10.5.0", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 12" } @@ -7532,7 +6226,6 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "cidr-regex": "^5.0.4" }, @@ -7545,7 +6238,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": ">=20" } @@ -7555,7 +6247,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -7565,7 +6256,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -7577,29 +6267,25 @@ "node >= 0.2.0" ], "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/npm/node_modules/just-diff": { "version": "6.0.2", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/npm/node_modules/just-diff-apply": { "version": "5.5.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/npm/node_modules/libnpmaccess": { "version": "10.0.3", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "npm-package-arg": "^13.0.0", "npm-registry-fetch": "^19.0.0" @@ -7609,13 +6295,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.6", + "version": "8.1.13", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.9.2", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -7629,14 +6314,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.6", + "version": "10.3.3", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.9.2", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -7653,13 +6337,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.20", + "version": "7.0.27", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "@npmcli/arborist": "^9.4.3" + "@npmcli/arborist": "^9.9.2" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -7670,7 +6353,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^19.0.0" @@ -7680,13 +6362,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.6", + "version": "9.1.14", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.9.2", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -7696,11 +6377,10 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", + "version": "11.2.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", @@ -7720,7 +6400,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "npm-registry-fetch": "^19.0.0" }, @@ -7733,7 +6412,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "aproba": "^2.0.0", "npm-registry-fetch": "^19.0.0" @@ -7743,11 +6421,10 @@ } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/git": "^7.0.0", "@npmcli/run-script": "^10.0.0", @@ -7760,21 +6437,19 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "11.3.5", + "version": "11.5.1", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": "20 || >=22" } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.5", + "version": "15.0.6", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", @@ -7798,7 +6473,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "brace-expansion": "^5.0.5" }, @@ -7814,7 +6488,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -7824,7 +6497,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "minipass": "^7.0.3" }, @@ -7837,7 +6509,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", @@ -7855,7 +6526,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "minipass": "^7.1.3" }, @@ -7868,7 +6538,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7881,7 +6550,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -7893,15 +6561,13 @@ "version": "4.0.0", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/npm/node_modules/minipass-sized": { "version": "2.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "minipass": "^7.1.2" }, @@ -7914,7 +6580,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "minipass": "^7.1.2" }, @@ -7926,15 +6591,13 @@ "version": "2.1.3", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/npm/node_modules/mute-stream": { "version": "3.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -7944,17 +6607,15 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } }, "node_modules/npm/node_modules/node-gyp": { - "version": "12.3.0", + "version": "12.4.0", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", @@ -7979,7 +6640,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "abbrev": "^4.0.0" }, @@ -7995,7 +6655,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -8005,7 +6664,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "npm-normalize-package-bin": "^5.0.0" }, @@ -8018,7 +6676,6 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "semver": "^7.1.1" }, @@ -8031,7 +6688,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -8041,7 +6697,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", @@ -8057,7 +6712,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" @@ -8071,7 +6725,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", @@ -8083,14 +6736,13 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -8101,7 +6753,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", @@ -8121,7 +6772,6 @@ "dev": true, "inBundle": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -8131,7 +6781,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -8140,11 +6789,10 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.5.0", + "version": "21.5.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", @@ -8176,7 +6824,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", @@ -8191,7 +6838,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -8204,11 +6850,10 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.1", + "version": "7.1.4", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8222,7 +6867,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -8232,7 +6876,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -8242,7 +6885,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -8252,7 +6894,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -8262,7 +6903,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "read": "^5.0.0" }, @@ -8274,7 +6914,6 @@ "version": "0.12.0", "dev": true, "inBundle": true, - "peer": true, "bin": { "qrcode-terminal": "bin/qrcode-terminal.js" } @@ -8284,7 +6923,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "mute-stream": "^3.0.0" }, @@ -8297,7 +6935,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -8307,15 +6944,13 @@ "dev": true, "inBundle": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.7.4", + "version": "7.8.5", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -8328,7 +6963,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": ">=14" }, @@ -8337,18 +6971,17 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "4.1.0", + "version": "4.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -8359,20 +6992,18 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.7", + "version": "2.8.9", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -8385,7 +7016,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -8399,15 +7029,13 @@ "version": "2.5.0", "dev": true, "inBundle": true, - "license": "CC-BY-3.0", - "peer": true + "license": "CC-BY-3.0" }, "node_modules/npm/node_modules/spdx-expression-parse": { "version": "4.0.0", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -8417,15 +7045,13 @@ "version": "3.0.23", "dev": true, "inBundle": true, - "license": "CC0-1.0", - "peer": true + "license": "CC0-1.0" }, "node_modules/npm/node_modules/ssri": { "version": "13.0.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "minipass": "^7.0.3" }, @@ -8438,7 +7064,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -8447,11 +7072,10 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.13", + "version": "7.5.22", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -8467,22 +7091,19 @@ "version": "0.2.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/npm/node_modules/tiny-relative-date": { "version": "2.0.2", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.16", + "version": "0.2.17", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -8499,7 +7120,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12.0.0" }, @@ -8517,7 +7137,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8530,7 +7149,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -8540,7 +7158,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", @@ -8551,11 +7168,10 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.25.0", + "version": "6.28.0", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18.17" } @@ -8564,15 +7180,13 @@ "version": "1.0.2", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/npm/node_modules/validate-npm-package-name": { "version": "7.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } @@ -8582,7 +7196,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "20 || >=22" } @@ -8592,7 +7205,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^4.0.0" }, @@ -8608,7 +7220,6 @@ "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { "signal-exit": "^4.0.1" }, @@ -8621,7 +7232,6 @@ "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": ">=18" } @@ -8632,7 +7242,6 @@ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8641,33 +7250,22 @@ "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", "engines": { "node": ">=14.0.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==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "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": "^2.1.0" + "mimic-fn": "^4.0.0" }, "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8679,7 +7277,6 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -8698,7 +7295,6 @@ "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8706,13 +7302,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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" }, @@ -8723,17 +7334,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8767,12 +7367,11 @@ } }, "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==", + "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": ">=18" }, @@ -8781,33 +7380,40 @@ } }, "node_modules/p-reduce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", - "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", + "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, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/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", - "peer": true, "engines": { - "node": ">=6" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { + "node_modules/p-try": { "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==", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", "dev": true, - "license": "BlueOak-1.0.0" + "license": "MIT", + "engines": { + "node": ">=4" + } }, "node_modules/parent-module": { "version": "1.0.1", @@ -8815,7 +7421,6 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -8824,20 +7429,18 @@ } }, "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==", + "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.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8849,7 +7452,6 @@ "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -8862,8 +7464,7 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "6.0.1", @@ -8871,7 +7472,6 @@ "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parse5": "^6.0.1" } @@ -8881,8 +7481,7 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-exists": { "version": "4.0.0", @@ -8894,17 +7493,6 @@ "node": ">=8" } }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8915,40 +7503,23 @@ "node": ">=8" } }, - "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==", - "dev": true, - "license": "MIT", - "peer": true - }, "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==", + "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": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "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" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8964,17 +7535,16 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -8986,44 +7556,55 @@ "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } }, "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==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", "dependencies": { + "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", - "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", + "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", - "process-warning": "^4.0.0", + "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", + "real-require": "^1.0.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "thread-stream": "^4.0.0" }, "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==", + "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", "dependencies": { "split2": "^4.0.0" } }, + "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", + "engines": { + "node": ">= 10.x" + } + }, "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==" + "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", @@ -9031,7 +7612,6 @@ "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" @@ -9046,7 +7626,6 @@ "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^2.0.0" }, @@ -9060,7 +7639,6 @@ "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" @@ -9075,7 +7653,6 @@ "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-try": "^1.0.0" }, @@ -9089,7 +7666,6 @@ "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^1.1.0" }, @@ -9097,24 +7673,12 @@ "node": ">=4" } }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, "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": ">=4" } @@ -9125,15 +7689,14 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8.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==", + "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", "bin": { @@ -9147,12 +7710,11 @@ } }, "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==", + "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": { "parse-ms": "^4.0.0" }, @@ -9163,26 +7725,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.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 + "license": "MIT" }, "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==" + "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", - "peer": 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", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } }, "node_modules/punycode": { "version": "2.3.1", @@ -9190,7 +7789,6 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -9205,60 +7803,55 @@ "node": ">=6" } }, - "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==", + "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": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/fast-check" } ], "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/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": { + "hookified": "^2.1.1" + }, "engines": { - "node": ">=8" + "node": ">=20" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "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": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } + "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.8", + "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": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -9269,16 +7862,12 @@ "rc": "cli.js" } }, - "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/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": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } + "license": "ISC" }, "node_modules/read-package-up": { "version": "12.0.0", @@ -9286,7 +7875,6 @@ "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", @@ -9300,12 +7888,11 @@ } }, "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==", + "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 OR CC0-1.0)", - "peer": true, "dependencies": { "tagged-tag": "^1.0.0" }, @@ -9322,7 +7909,6 @@ "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/normalize-package-data": "^2.4.4", "normalize-package-data": "^8.0.0", @@ -9337,49 +7923,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/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==", + "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": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" + "tagged-tag": "^1.0.0" }, - "funding": { - "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==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { - "node": ">=16" + "node": ">=20" }, "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/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 OR CC0-1.0)", - "peer": true, - "dependencies": { - "tagged-tag": "^1.0.0" - }, + "license": "MIT", "engines": { "node": ">=20" }, @@ -9393,7 +7958,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -9404,223 +7968,58 @@ "util-deprecate": "~1.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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "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_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@pnpm/npm-conf": "^3.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "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", - "engines": { - "node": ">=0.10.0" - } - }, - "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", - "engines": { - "node": ">=0.10.0" - } - }, - "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/readable-stream-v2": { + "name": "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": "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" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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": "MIT", - "peer": true, - "engines": { - "node": ">=8" + "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/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==", + "node_modules/readable-stream-v3": { + "name": "readable-stream", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "global-dirs": "^0.1.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": ">=8" + "node": ">= 6" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "node_modules/readable-stream-v4": { + "name": "readable-stream", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", "dev": true, "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.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, - "license": "ISC", - "peer": true, "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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", - "dev": true, - "license": "ISC", - "peer": true, - "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" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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==", - "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" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/safe-buffer": { + "node_modules/readable-stream-v4/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==", @@ -9641,404 +8040,150 @@ ], "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==", - "engines": { - "node": ">=10" - } - }, - "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==", - "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" - }, - "bin": { - "semantic-release": "bin/semantic-release.js" - }, - "engines": { - "node": "^22.14.0 || >= 24.10.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "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==", - "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_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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "escape-string-regexp": "5.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "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==", - "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" - } - }, - "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==", - "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" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "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_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/readable-stream-v4/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==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "safe-buffer": "~5.2.0" } }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "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/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/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": { - "path-key": "^4.0.0" + "@pnpm/npm-conf": "^3.0.2" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=14" } }, - "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/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": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "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/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, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "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/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": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=8" } }, - "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/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + "license": "MIT" }, - "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/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", - "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10" } }, - "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/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", - "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" + "@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": { + "semantic-release": "bin/semantic-release.js" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": "^22.14.0 || >= 24.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/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": "ISC", - "peer": true, + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "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==", + "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": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -10052,7 +8197,6 @@ "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10060,29 +8204,6 @@ "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==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "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==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10107,12 +8228,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 +8246,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 +8261,6 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -10150,7 +8274,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 +8289,6 @@ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -10176,8 +8298,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 +8306,6 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.0" } @@ -10196,7 +8316,6 @@ "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -10210,7 +8329,6 @@ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -10221,7 +8339,6 @@ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -10230,39 +8347,28 @@ } }, "node_modules/sinon": { - "version": "19.0.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", - "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "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.2", - "@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", "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 +8377,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 +8391,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10294,8 +8400,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 +8408,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 +8418,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 +8426,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 +8454,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 +8488,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==", @@ -10411,55 +8499,7 @@ "strip-ansi": "^6.0.1" }, "engines": { - "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": ">=8" } }, "node_modules/strip-ansi": { @@ -10475,16 +8515,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 +8531,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,7 +8595,6 @@ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -10587,27 +8606,12 @@ "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" }, @@ -10621,7 +8625,6 @@ "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.16" } @@ -10632,7 +8635,6 @@ "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-stream": "^3.0.0", "temp-dir": "^3.0.0", @@ -10652,7 +8654,6 @@ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -10666,7 +8667,6 @@ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=12.20" }, @@ -10689,119 +8689,12 @@ "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" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "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==", - "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/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==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "any-promise": "^1.0.0" } @@ -10812,7 +8705,6 @@ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -10821,46 +8713,38 @@ } }, "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==", + "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", "dependencies": { - "real-require": "^0.2.0" + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" } }, - "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==", + "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": "3" + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" } }, - "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-v2": { + "name": "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 +8753,6 @@ "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "convert-hrtime": "^5.0.0" }, @@ -10881,9 +8764,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 +8774,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 +8790,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 +8809,6 @@ "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -10968,82 +8816,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" + "tsx": "dist/cli.mjs" }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "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 +8854,6 @@ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } @@ -11063,7 +8864,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -11076,36 +8876,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" @@ -11115,55 +8915,15 @@ "pnpm": ">= 10" }, "peerDependencies": { - "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" + "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/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 +8933,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 +8970,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "bin": { "uglifyjs": "bin/uglifyjs" }, @@ -11222,23 +8978,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.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "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 +8999,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 +9022,6 @@ "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crypto-random-string": "^4.0.0" }, @@ -11287,8 +9037,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 +9045,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -11307,18 +9055,16 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } }, "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", - "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -11328,14 +9074,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": { @@ -11353,29 +9091,30 @@ "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" - } - }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "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 +9125,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", @@ -11408,7 +9154,6 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11418,23 +9163,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 +9183,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 +9224,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 +9244,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 +9261,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 +9288,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 +9345,146 @@ } }, "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": "0.0.0-development", + "license": "MIT", + "dependencies": { + "@binden/logger": "^0.0.0-development" + }, + "devDependencies": { + "@types/sinon": "^22.0.0", + "fast-json-stringify": "^7.1.0", + "node-fetch": "^3.3.2", + "readable-stream-v2": "npm:readable-stream@^2.3.8", + "readable-stream-v3": "npm:readable-stream@^3.6.2", + "readable-stream-v4": "npm:readable-stream@^4.8.0", + "sinon": "^22.1.0", + "through2-v2": "npm:through2@^2.0.5" + }, + "engines": { + "node": ">=24.14.1" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } + }, + "packages/body": { + "name": "@binden/body", + "version": "0.0.0-development", + "license": "MIT", + "devDependencies": { + "@binden/logger": "^0.0.0-development", + "ajv": "^8.20.0", + "undici": "^8.11.2" + }, + "engines": { + "node": ">=24.14.1" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + }, + "peerDependencies": { + "binden": "^0.0.0-development" + } + }, + "packages/body/node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "packages/compression": { + "name": "@binden/compression", + "version": "0.0.0-development", + "license": "MIT", + "devDependencies": { + "undici": "^8.3.0" + }, + "engines": { + "node": ">=24.14.1" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + }, + "peerDependencies": { + "binden": "^0.0.0-development" + } + }, + "packages/compression/node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "packages/logger": { + "name": "@binden/logger", + "version": "0.0.0-development", + "license": "MIT", + "dependencies": { + "pino": "^10.4.0" + }, + "engines": { + "node": ">=24.14.1" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } + }, + "packages/multipart": { + "name": "@binden/multipart", + "version": "0.0.0-development", + "license": "MIT", + "dependencies": { + "@types/busboy": "^1.5.5", + "busboy": "^1.6.0" + }, + "devDependencies": { + "undici": "^8.3.0" + }, + "engines": { + "node": ">=24.14.1" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + }, + "peerDependencies": { + "binden": "^0.0.0-development" + } + }, + "packages/multipart/node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } } } } diff --git a/package.json b/package.json index 9ad9fe36..52ac14b5 100644 --- a/package.json +++ b/package.json @@ -1,80 +1,62 @@ { - "name": "binden", - "version": "2.0.4", - "description": "A simple HTTP server framework", - "main": "dist/index.js", + "name": "binden-monorepo", + "private": true, + "description": "binden HTTP framework and its official packages", "type": "module", - "types": "dist/index.d.ts", + "workspaces": [ + "packages/logger", + "packages/binden", + "packages/body", + "packages/compression", + "packages/multipart" + ], "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", + "acceptance": "tsc -p examples && tsc -p scripts/acceptance && 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 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 --", "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", + "eslint": "eslint .", + "eslint:fix": "eslint . --fix", + "pack:check": "node scripts/pack-check.mjs", + "prepare": "husky && npm run build", "prettier": "prettier -c .", "prettier:write": "prettier -c -w .", - "preversion": "npm run docs:build", - "test": "mocha" + "release:check": "node scripts/release.mjs check", + "release:rehearsal": "node scripts/release-rehearsal.mjs", + "test": "npm test --workspaces --if-present --", + "test:tooling": "node --test --test-timeout=120000 scripts/*.test.mjs" }, "repository": { "type": "git", "url": "git+https://github.com/binden-js/binden.git" }, - "keywords": [ - "HTTP", - "REST", - "web", - "framework" - ], "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", - "dependencies": { - "@binden/logger": "^2.0.3" - }, "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/exec": "^7.1.0", + "@types/node": "^24.19.2", + "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.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" - }, - "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + "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/CHANGELOG.md b/packages/binden/CHANGELOG.md new file mode 100644 index 00000000..93e02065 --- /dev/null +++ b/packages/binden/CHANGELOG.md @@ -0,0 +1,183 @@ +# Changelog + +## [2.0.4](https://github.com/binden-js/binden/compare/v2.0.3...v2.0.4) (2025-04-27) + +### Dependencies + +- bump @binden/logger from 2.0.1 to 2.0.3 ([c8ce978](https://github.com/binden-js/binden/commit/c8ce978ffe84d8464490a11a6f3f3305c9ff4309)) + +## [2.0.3](https://github.com/binden-js/binden/compare/v2.0.2...v2.0.3) (2024-06-29) + +### Dependencies + +- bump @binden/logger from 2.0.0 to 2.0.1 ([7dc2ed3](https://github.com/binden-js/binden/commit/7dc2ed38b5e44804cb893a5e760448c4c02b2323)) + +## [2.0.2](https://github.com/binden-js/binden/compare/v2.0.1...v2.0.2) (2024-02-01) + +### Bug Fixes + +- add missing files to the `npm` package ([d8db0fe](https://github.com/binden-js/binden/commit/d8db0feb894595e70d5aad8a6ef0147b3daa0cfe)), closes [#150](https://github.com/binden-js/binden/issues/150) + +## [2.0.1](https://github.com/binden-js/binden/compare/v2.0.0...v2.0.1) (2024-01-31) + +### Dependencies + +- bump @binden/logger from 1.0.13 to 2.0.0 ([eacc604](https://github.com/binden-js/binden/commit/eacc604ae4b68f8ff5a242d74d5eb018a36ab6fa)) + +## [2.0.0](https://github.com/binden-js/binden/compare/v1.2.5...v2.0.0) (2024-01-30) + +### ⚠ BREAKING CHANGES + +- drop `Node.js` `<20.11.0` support + +### Performance Improvements + +- drop `Node.js` `<20.11.0` support ([0f6f259](https://github.com/binden-js/binden/commit/0f6f2597443ca522c77d7537467af16ddc70c4be)) + +## [1.2.5](https://github.com/binden-js/binden/compare/v1.2.4...v1.2.5) (2023-12-30) + +### Dependencies + +- bump @binden/logger from 1.0.11 to 1.0.13 ([65b4006](https://github.com/binden-js/binden/commit/65b4006e37ac11758ca77e221c6a8c3f97141418)) + +## [1.2.4](https://github.com/binden-js/binden/compare/v1.2.3...v1.2.4) (2023-11-27) + +### Dependencies + +- bump @binden/logger from 1.0.10 to 1.0.11 ([2559214](https://github.com/binden-js/binden/commit/2559214ea64e264cf1ad9bfba1eecf9d09fa1992)) + +## [1.2.3](https://github.com/binden-js/binden/compare/v1.2.2...v1.2.3) (2023-09-15) + +### Dependencies + +- bump @binden/logger from 1.0.9 to 1.0.10 ([cd189b9](https://github.com/binden-js/binden/commit/cd189b97051430c009303155edfaef9881a4ab02)) + +## [1.2.2](https://github.com/binden-js/binden/compare/v1.2.1...v1.2.2) (2023-09-07) + +### Dependencies + +- bump @binden/logger from 1.0.8 to 1.0.9 ([7e5feed](https://github.com/binden-js/binden/commit/7e5feed90de43f283e3770b2d53f376b02187071)) + +## [1.2.1](https://github.com/binden-js/binden/compare/v1.2.0...v1.2.1) (2023-05-29) + +### Dependencies + +- bump @binden/logger from 1.0.7 to 1.0.8 ([a40164c](https://github.com/binden-js/binden/commit/a40164c7d13312f272a0e69a8cb2ed90f7ff3e14)) + +## [1.2.0](https://github.com/binden-js/binden/compare/v1.1.7...v1.2.0) (2023-02-28) + +### Features + +- allow to use functions as middlewares ([10333cb](https://github.com/binden-js/binden/commit/10333cbc0aad25a833ef7d4903dc4c537e0c1efa)) + +### Dependencies + +- bump `@binden/logger` from `1.0.6` to `1.0.7` ([6a7611b](https://github.com/binden-js/binden/commit/6a7611b7e9bc06a475544bce578b72904b1ebd66)) + +## [1.1.7](https://github.com/binden-js/binden/compare/v1.1.6...v1.1.7) (2023-02-12) + +### Bug Fixes + +- **Context:** allow to pass a custom `stringify` function to `.json()` ([f9317b9](https://github.com/binden-js/binden/commit/f9317b9c217c08c9d2db8cf623d7da5179a10463)) + +## [1.1.6](https://github.com/binden-js/binden/compare/v1.1.5...v1.1.6) (2023-02-12) + +### Performance Improvements + +- **BindenResponse:** allow to pass a custom `Stats` to `.sendFile()` ([c3a9a8e](https://github.com/binden-js/binden/commit/c3a9a8e042e3c6937751bb2f8a7b3d67ceb6e361)) +- **BindenResponse:** allow to pass a custom `stringify` function to `.json()` ([e90a580](https://github.com/binden-js/binden/commit/e90a5803ea5175e44013da667b555b1d8fe07f24)) + +### Dependencies + +- bump `binden/logger` from `1.0.5` to `1.0.6` ([9333718](https://github.com/binden-js/binden/commit/93337181f98f7367d574c1889e09481022d8849e)) + +## [1.1.5](https://github.com/binden-js/binden/compare/v1.1.4...v1.1.5) (2022-11-08) + +### Dependencies + +- bump @binden/logger from 1.0.4 to 1.0.5 ([236f61c](https://github.com/binden-js/binden/commit/236f61cddfe8fb6e79eec4d269a0f78aca52cc8b)) + +## [1.1.4](https://github.com/binden-js/binden/compare/v1.1.3...v1.1.4) (2022-10-09) + +### Dependencies + +- bump @binden/logger from 1.0.3 to 1.0.4 ([0a459d1](https://github.com/binden-js/binden/commit/0a459d1baeb7799b1a2d4cabc3e8eb885d98dd6a)) + +## [1.1.3](https://github.com/binden-js/binden/compare/v1.1.2...v1.1.3) (2022-09-19) + +### Performance Improvements + +- add generics to the `BindenResponse` class ([8315288](https://github.com/binden-js/binden/commit/8315288246d57a97dfecf20f120ac1b8c01adeba)) + +## [1.1.2](https://github.com/binden-js/binden/compare/v1.1.1...v1.1.2) (2022-09-06) + +### Dependencies + +- bump @binden/logger from 1.0.2 to 1.0.3 ([3c5c20b](https://github.com/binden-js/binden/commit/3c5c20bdc12dade5237ab22de5e54407a8ceb79c)) + +## [1.1.1](https://github.com/binden-js/binden/compare/v1.1.0...v1.1.1) (2022-08-30) + +### Dependencies + +- bump @binden/logger from 1.0.0 to 1.0.2 ([e026a4f](https://github.com/binden-js/binden/commit/e026a4f45d373bb065ee9060414f9853c1932313)) + +## [1.1.0](https://github.com/binden-js/binden/compare/v1.0.0...v1.1.0) (2022-08-19) + +### Features + +- **BindenError:** add the `cause` option ([986a52c](https://github.com/binden-js/binden/commit/986a52cf797568a3ef9aca6cc9aefcf74e8d2bd7)) + +## 1.0.0 (2022-08-16) + +### ⚠ BREAKING CHANGES + +- rename `Kauai` to `Binden` +- drop Node.js `<18.7.0` support + +### Features + +- add `Context` ([ebfc75d](https://github.com/binden-js/binden/commit/ebfc75da9037280b9f3ebfbb8c301fe5f6ddf30f)) +- add `Kauai` ([dab27ab](https://github.com/binden-js/binden/commit/dab27ab82e5995b2a062b69d4d1308abc48c9e28)) +- add `KauaiError` ([9498106](https://github.com/binden-js/binden/commit/949810618e9f96b178376a8604d9c4e146d0a701)) +- add `KauaiRequest` ([4986039](https://github.com/binden-js/binden/commit/4986039f9050e3c2619a6ee73781958a457e8f3b)) +- add `KauaiResponse` ([2313985](https://github.com/binden-js/binden/commit/23139857603b301d40a4bb3502b4245384d127b0)) +- add `Middleware` ([86e96fb](https://github.com/binden-js/binden/commit/86e96fb548ce68de8294626eaf0f4ffbdfa7ab82)) +- add `Router` ([55f2780](https://github.com/binden-js/binden/commit/55f27806fdb7499b5174f8f44303aa4f31f00a97)) +- add logger ([7157667](https://github.com/binden-js/binden/commit/7157667f7506c2b6f393454294c5edbbf382e7d0)) +- **Headers:** add `Content-Encoding` ([82d1cc2](https://github.com/binden-js/binden/commit/82d1cc25aa43cdeb1e1da288002923768a632ed4)) +- **Headers:** add `Content-Type` ([f9fc897](https://github.com/binden-js/binden/commit/f9fc8974c9d76eb09005169462edd8f7b3892bf9)) +- **headers:** add `Forwarded` ([abe665c](https://github.com/binden-js/binden/commit/abe665cdf00d0ad1957d6682e4e1dd824ddf0d02)) +- **Headers:** add `If-Modified-Since` ([1a894b9](https://github.com/binden-js/binden/commit/1a894b99806eb37ba21400aaa5fcbff49022425b)) +- **headers:** add Content-Range ([a0f68a0](https://github.com/binden-js/binden/commit/a0f68a0d8621690229be04389ad6d921ce529fca)) +- **headers:** add Cookie ([e78474e](https://github.com/binden-js/binden/commit/e78474ef61dbdf62255a3b03f767d9c27542a110)) +- **headers:** add Range ([2473776](https://github.com/binden-js/binden/commit/247377654c233ce78f32437ffd91f646124439a0)) +- **Headers:** add the `Accept-Encoding` header ([a26a658](https://github.com/binden-js/binden/commit/a26a6589cc1d3dd504fc395ec8d905256d49c07f)) +- **KauaiRequest:** parse the `Cookie` header ([4c4fb44](https://github.com/binden-js/binden/commit/4c4fb4427555e1c311570d214ee9cbd81555582e)) +- **KauaiRequest:** parse the `Forwarded` header ([bfb9080](https://github.com/binden-js/binden/commit/bfb908069147a6b76d1fa565a3a0981e6b90d176)) +- **KauaiRequest:** parse the `Range` header ([a555d7e](https://github.com/binden-js/binden/commit/a555d7ebbecb81a6dc55ef117658a7f257f74e5a)) + +### Bug Fixes + +- **KauaiResponse:** allow the `.json()` method to accept arrays ([8637ac8](https://github.com/binden-js/binden/commit/8637ac8a527340eb411826000f7c33a5e81e2c0e)) + +### Performance Improvements + +- add `id` to the `KauaiRequest` class ([8b2048c](https://github.com/binden-js/binden/commit/8b2048cdda85dba7d3b05824b074b41ded8a1b2e)) +- **Context:** add the `.setHeader()` method ([4e48d2e](https://github.com/binden-js/binden/commit/4e48d2e2cde9dd0deb7c9805a2feea962d78a7db)) +- drop Node.js `<18.7.0` support ([7cb854e](https://github.com/binden-js/binden/commit/7cb854ee216e7b02a3d4cf942afc05a93cbfbf8b)) +- **Headers:** add the `Authorization` header ([dc53f28](https://github.com/binden-js/binden/commit/dc53f28a5bd7267f8eca3cdf640993afb5c34a45)) +- **sendFile:** add partial request support ([b319879](https://github.com/binden-js/binden/commit/b31987905e553393cf71d5a62930ecaa06d054a4)) + +### Dependencies + +- add `@binden/logger` ([e38d95f](https://github.com/binden-js/binden/commit/e38d95f95eb2e85f2c0a40ce65d50bbd610d543c)) +- add `@kauai/logger` ([998056b](https://github.com/binden-js/binden/commit/998056b3dc208ac2b84546bf3227af4cbf8f9a1b)) +- add `pino` ([6a449ac](https://github.com/binden-js/binden/commit/6a449ac25e64be022752ea4b7b5c9cdef91cb631)) +- remove `pino` ([753452c](https://github.com/binden-js/binden/commit/753452cbeb68ebab380d161ec84fd83714c0c3b0)) +- upgrade `@kauai/logger` to `v1.0.1` ([5d500df](https://github.com/binden-js/binden/commit/5d500df1aeec5b3303a163ec6b5ff3bf945014b2)) +- upgrade `pino` to `v6.12.0` ([eb8d21f](https://github.com/binden-js/binden/commit/eb8d21f49a8367fdeee2f87a3613c98935750629)) +- upgrade `pino` to `v6.13.0` ([13f7e8a](https://github.com/binden-js/binden/commit/13f7e8a92d4445db662dd7c2250dffacd845394a)) + +### Miscellaneous Chores + +- rename `Kauai` to `Binden` ([17b83ed](https://github.com/binden-js/binden/commit/17b83ed23df79a1baf934742af9677a1c440651d)) diff --git a/packages/binden/LICENSE b/packages/binden/LICENSE new file mode 100644 index 00000000..d542516c --- /dev/null +++ b/packages/binden/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/packages/binden/README.md b/packages/binden/README.md new file mode 100644 index 00000000..4b6c13c0 --- /dev/null +++ b/packages/binden/README.md @@ -0,0 +1,1287 @@ +# 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/)). + +## Installation + +```bash +npm install binden +``` + +## Usage + +### Binden + +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 | +| ------------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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) | +| `etag` | `false` | A strong `ETag` for the `200` responses to `GET` and `HEAD`, and the preconditions of the request (`304 Not Modified`, `412 Precondition Failed`) against 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)) | +| `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, synchronously (required by the types when the state has required fields; a promise fails the request) | +| `trust_proxy`, `allowed_hosts`, `request_id`, `encoded_slashes`, `query` | see [Request options](#request-options) | The options of the requests | + +- `.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"; + +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, and so is anything but an instance made by the constructors of these classes (or of their subclasses), told by their private brands without running a trap. That includes a proxy of a request or a response of the application (whose traps would run later), an object made with `Object.create()` of their prototypes, and an instance of the classes of another application. 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 +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 with the pathname of `context.url`: as received, but with the characters that a URL encodes, such as `"`, `{` or `}`, percent-encoded: `/a%22b` matches the requests for `/a"b` and `/a%22b`, and `/a"b` matches none) or for the paths that a RegExp matches (a RegExp keeps its flags, except `g` and `y`, which would make `test()` stateful). It throws a `TypeError` without a middleware or router, and for a first argument that is neither one nor a path, such as a stack (added with `.mount()`) + +```typescript +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): Promise => + context.json({ message: "Hello World!" }); +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 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 +import { Binden } from "binden"; + +const app = new Binden() + .route("/users/:id", (context) => context.json({ id: context.params.id })) + .route("/files/*path", (context) => context.text(context.params.path)); +``` + +- `.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, Router, Stack } from "binden"; + +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); +``` + +- `.off()` - remove middlewares and routers added by `.use()` (returns the removed items) + +```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); +``` + +- `.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. Its calls never fail: a logger that throws (or whose `child()` throws, or gives a logger that throws) is ignored, and a promise it returns is observed (a `child()` that returns one, or anything but a logger, gives a new logger of binden over the logger itself), so that logging never breaks the handling of a request (also in the other packages, which log through it). The framework writes its own records (e.g. the access log, and the failures that it logs) by a private path of its loggers, which the application cannot replace: a method (or `child()`) replaced on `context.log`, or on a logger of binden given as `logger`, applies only to the calls of the application itself, and what getting the logger throws (e.g. a `log` getter of a subclass of `Context`) or what a field of a record throws (e.g. an `id` getter) drops that record only, and the request goes on. `context.log`, and every `child()` of a logger of binden, is a new logger of binden: never one that the application holds. A logger of binden that a `child()` gives (or that is given as `logger`) is reached by its private path too, and is never nested: a new logger of binden made over one (by its `child()`, or by its constructor) writes to the logger under it instead, so that no logger of binden keeps the ones made before it (e.g. through a `child()` that gives the last `context.log`), and no depth of such wrapping makes a call fail. binden tells its own loggers (e.g. `context.log`) by their private brand, without running code of the application. Anything else, also a copy of one (e.g. `{ ...context.log }`), a logger derived from one (e.g. `Object.create(context.log)`) or a proxy of one, is wrapped as any custom logger, and keeps what it references: the flattening applies over a logger of binden itself only. A `child()` that gives such a copy of the last `context.log` chains them: each keeps the loggers made before it, and a call goes down the whole chain, whose records are dropped (the call itself never fails) once it is deeper than the stack. The methods of binden's loggers are bound to them, so that a logger derived from one logs through it, and the methods that it overrides apply to the calls made on the derived logger itself only: the records of a request (the application's own and the framework's) go through its `child()`, whose inherited one gives a child of the original logger, without the overrides. To change those records too, override `child()` as well (e.g. to give a logger derived from the child, with the same overrides), or give a custom logger (any object with the methods of `ILogger`). + +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. + +```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). Anything but a context made by its constructor (or the one of a subclass), e.g. a proxy of one or an object made with `Object.create()` of its prototype, is described by its type; +- a `body` that the application logs (e.g. `context.log.debug("Received", { body })`) is redacted (the body parsers never log the bodies themselves); +- an `error` (and `err`, the key under which `pino` logs 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`). `new Binden()` reads its methods to check it, and what that runs (e.g. the `get` trap of a proxy) is formatted; no other trap runs. The `logger` of `new Context()` is not checked: it is wrapped, and a call of it that fails is ignored. The default `serializers` are exported to build on, and `createSerializers({ error_details: true })` creates them with an `error` that adds its message, its stack, its cause and any `code` (which may quote the input of a request). The `error_details` option of `@binden/logger` does not apply to them: the `error` of binden replaces the one of the logger. + +```typescript +import { Binden, createSerializers, serializers } from "binden"; +import { Logger } from "@binden/logger"; + +const logger = new Logger({ level: "debug", serializers }); +const app = new Binden({ logger }); + +// Log the request bodies, and the details of the errors, as well: those that +// the application logs (`error`) and those of the framework (`failure`) +const verbose = new Logger({ + level: "debug", + serializers: { + ...createSerializers({ error_details: true }), + body: (body: unknown): unknown => body, + }, +}); +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 layer that has started the reply (e.g. `/api/users/:id`), or `` (a reply of the framework) | +| `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: the writes that Node.js accepts (not one after the end) | +| `id` | The id of the request | +| `aborted` | Whether the request has been aborted (see [Lifecycle](#lifecycle)) | + +The `route` of a `use()` without a path is the template of its mounts (`*` at the root), and the replies of the framework (its `404`, `405` and the `204` of `OPTIONS`) are ``. + +```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): + +- `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. + +Every error that binden creates and throws, rejects with or hands over has its stack formatted when it is created, and so has every error that @binden/body, @binden/multipart, @binden/compression and @binden/logger create, without exception: e.g. the `BindenAbortError` of `context.signal`, the argument errors of a call (e.g. the `TypeError` of `response.status(99)`, thrown or rejected to the caller as by any Node.js API), the `400` that `request.URL` throws for a request-target or a host that it refuses, the `403` and the `404` that `sendFile()` rejects with for a path that it refuses (e.g. of a dotfile), the `BindenError` of `context.throw()`, the premature close and the length mismatch that a `send()` rejects with or destroys its source with, the `TypeError` of a refused pipe, the `503` of the deadline that the error handler is given, the `500` of an error handler that has not settled in time, which its pending sends reject with, and the `400`, `413` and `415` of the parsers of a body. A stream (e.g. minipass, which keeps the error that it is destroyed with) or an application that keeps one retains neither the request nor the response. + +A `BindenError` formats its stack in its constructor, also one that the application creates. An instance of a subclass is not formatted there, so that its stack keeps the header that its own constructor gives it (e.g. its `name`): an application that keeps such errors formats them itself (e.g. by reading `stack` at the end of the constructor of its subclass). + +Every entry point that binden and its packages define formats what it throws, or what it rejects with from its synchronous part, before the caller gets it: each exported function (e.g. `formatStack()`, `validateRequestOptions()`, `createSerializers()`, `shutdown()`, `skipCompression()`, `isCompressible()` and `multipartInfo()`), each constructor and static method of an exported class, and each method and accessor (also one named by a symbol) that binden defines on the prototype of an exported class, e.g. the constructors and the parsers of the header classes, `new BindenError()` and `new BindenAbortError()`, the constructors of `Middleware`, `Router`, `Context` and `Binden`, `use()`, `route()`, `mount()` and `off()` of a stack, the methods of a `Router` (`on()`, `off()`, one per method, and `middlewares`), `createServer()`, `createSecureServer()` and `callback()` (also its listener, and the `listen()` of a server that they register for `shutdown()`, which keeps the coercions of Node.js), `new BindenRequest()` and every method and getter of a request, the methods of `BindenResponse` and `Context`, `BodyParser`, `Multipart`, `Compression` (also the methods that it installs on a response that it may compress: `writeHead()`, `write()`, `end()` and `flush()`) and `Logger`. That is its own errors, the errors that V8 creates while the frames of binden are on the stack (e.g. for a revoked proxy, a destructuring of `null`, a call of a value that is not of the declared type, or what `JSON.stringify()` throws in `response.json()`, such as its `TypeError` for a nested `bigint`), the argument errors of Node.js through a method that binden overrides (e.g. of `setHeader()`, `writeHead()`, `write()`, `end()`, `addTrailers()`, `setTimeout()` and the listener methods, such as `on()` and `emit()`: the `TypeError` of a header value with a newline, or the `RangeError` of `writeHead(99)`), and an error that a getter, a proxy or a function of the application throws itself (e.g. the `stringify` given to `json()`), which is that same error, formatted. A value thrown that is not an error is passed on as it is. What fails later, asynchronously, is handed over as it is (e.g. the `ENOENT` of `sendFile()`, the `EADDRINUSE` of a server, the `EPIPE` of a connection, or the error that a stream emits), as is an error that Node.js or the application creates otherwise; `shutdown()` of a value that is not a server rejects with its formatted error, also when it fails once awaited. The methods of Node.js that binden does not define, which a response inherits unchanged (e.g. `hasHeader()`, `getHeaderNames()` and `cork()`), behave as on a plain `node:http` response: what they throw is not formatted, and an application that keeps it without reading its `stack` keeps the response with it. A test enumerates the public surface of every package at run time, and calls each entry point with hostile values in each of its argument positions. + +The argument errors of binden's own methods are `TypeError`s of binden, formatted, thrown (or rejected with, by a method that returns a promise) before anything of the response is changed. Each checks the type of the value first, and its message describes a value that it refuses without converting it: a string, a number, a boolean, `undefined` and `null` as they are, a `bigint` with its `n`, and anything else by its type, `(a symbol)`, `(a function)` or `(an object)` (e.g. `Status code (a symbol) is invalid`), so that no `toString()` of the application runs and no conversion throws. They are the errors of `status()` (a status code that is not an integer from 100 to 599), `type()`, `attachment()`, `vary()` (a name that is not a token, also anything but a string, e.g. `null`), `set()` (anything but an object, e.g. `null`, or a string, whose characters would be headers), `clearCookie()` and `new Cookie()` (see [Headers](#headers)), the encoding of `send()`, `write()` and `end()` (`Unknown encoding: …`, with the code `ERR_UNKNOWN_ENCODING` for `write()` and `end()`), `json()` (a value that has no JSON text, a `stringify` that is not a function or that does not return a string), `redirect()` (a status that is not a redirect, a location that is not a valid URL or that it refuses, options that are not an object, an `allow_external` that is not a boolean), `form()` (anything but `URLSearchParams`) and `send()` (data that it cannot send, a view of a detached `ArrayBuffer`, a 1xx status). The same holds for the entry points that take options, an init, a header or an offer: options and inits are objects, or `undefined` where they are optional, and anything else (also a function) throws ("`options` is not an object", or "`init` is not an object" for a header, followed by the value, described, where binden's newer checks add it), e.g. the options of `new Binden()`, `createServer()`, `createSecureServer()`, `new Router()`, the constructor of a `Middleware`, `new Context()`, `new BindenError()` (also through `context.throw()`), `validateRequestOptions()`, `createSerializers()` and `shutdown()`, and the init of every header class, each option read once; a parser of a header for anything but what it declares (see [Headers](#headers): a malformed header is not refused, it gives what the parser gives for it, e.g. `null` or an empty array); each offer of `negotiate()`, of `request.accepts()`, `acceptsCharsets()`, `acceptsEncodings()` and `acceptsLanguages()`, refused as an invalid one (e.g. "Invalid media type: (an object)"); the name of `request.header()` ("The name is not a string: …") and the method of `router.middlewares()`, for anything but a string, and a method of `router.on()` and `off()` that is not a token, described without converting it; the status of `new BindenError()`, checked before its default message is looked up, so that it is never converted; the reason of `new BindenAbortError()`, for anything but `client_closed`, `deadline` and `shutdown`; a `charset` of `ContentType` that is neither a string nor `null`, for every media type ("`charset` is not a string: …"); the request, the response and `report` of `new Context()` ("`request` is not a BindenRequest: …", "`response` is not a BindenResponse: …", "`report` is not a function: …"), each an instance made by its constructor (or the one of a subclass), told by its private brand: never a proxy of one, whose traps would run later (e.g. once the response is closed), an object of another realm, or one made with `Object.create()` of its prototype; and the encoding of a write through @binden/compression (`Unknown encoding: …`, with the code `ERR_UNKNOWN_ENCODING`). The methods that binden defines over those of Node.js (`writeHead()`, `writeHeader()`, `write()`, `end()`, `flushHeaders()`, `setHeader()`, `getHeader()`, `appendHeader()`, `setHeaders()`, `removeHeader()`, `addTrailers()`, `setTimeout()`, `destroy()`, `writeEarlyHints()`, `writeInformation()`, `addListener()`, `on()`, `once()`, `prependListener()`, `prependOnceListener()`, `rawListeners()` and `emit()`), and the constructor of `BindenResponse`, keep the coercions of Node.js (e.g. `setHeader("x-count", 5)`, `writeHead("200")`, a value of a header or the name of an event), and format what Node.js or V8 throws for their arguments before the caller gets it (e.g. the `TypeError` of a header value with a newline, the `RangeError` of `writeHead(99)`, or the error of a revoked proxy). An error of the application that runs inside them is that same error, formatted: of a getter, of the trap of a proxy, of the `toString()` of a cookie, of a listener that `emit()` runs, or of an override of a subclass. The values of the headers of `set()` are set by `setHeader()`. The conversions that remain are these: `String()`, once, of the values of the inits of the headers that [Headers](#headers) lists (the name, the value, `domain` and `path` of a `Cookie`, the media range and the parameter values of `Accept`, the media type and the parameter values of `ContentType`, the charset of `AcceptCharset`, the language range of `AcceptLanguage` and the credentials of `Authorization`), and of the name, `domain` and `path` given to `clearCookie()`; the coercions of Node.js through its overrides (the names and the values of headers, the status code of `writeHead()` and the name of an event of the listener methods); and those of the constructor of `File` of Node.js, which `MultipartFile` of @binden/multipart extends (its name, `type` and `lastModified`), as for a plain `File`. The date of an `IfModifiedSince` given as a number or a string is read by `new Date()`, and the booleans of a `Cookie` (`secure`, `http_only` and `partitioned`), `context.done` and `router.guarded` by `Boolean()`, neither of which runs code of the application. + +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). A work that can no longer change the response (see below, e.g. a middleware whose pipe has failed, or its `close` listeners) cannot change the deadline either: its `context.setTimeout()` is ignored, without checking its argument. 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`). The work that still runs can no longer change the response (from before `context.signal` is aborted: its abort listeners, and the work that resumes once it is aborted, such as a `catch` that replies, or `stream.pipeline()` that destroys the response, change nothing either), so that it cannot replace or cut the reply of the error handler: its replies through the context or the response (`send()`, `json()`, `text()`, `html()`, `form()`, `redirect()`, `sendFile()` and `flush()`), the methods of Node.js (`write()`, `end()`, `writeHead()` and its deprecated alias `writeHeader()`, `flushHeaders()`, `setHeader()`, `appendHeader()`, `setHeaders()`, `removeHeader()`, `addTrailers()`, `destroy()`, `setTimeout()`, `writeContinue()`, `writeProcessing()`, `writeEarlyHints()` and `writeInformation()`), `statusCode`, `statusMessage`, `sendDate`, `shouldKeepAlive`, `status()` and the other setters of the response, and its cookies (also through a `response.cookies` obtained before) do nothing, without validating their arguments. An array of the values of a header is copied when it is set (also by `appendHeader()` and `setHeaders()`) and when it is read (`getHeader()`, `getHeaders()`), so that changing an array kept from before changes nothing either. The callback of an ignored `write()` or `end()` is called once, asynchronously and without an error (`write()` returns `true`). Each send keeps the owner of the work that has started it: a stream that it reads, a file that it opens and a compressor that it writes to no longer change the response either. In the same way, a listener added to the response (e.g. of `drain`, `finish` or `close`) and the callback of a `write()` or an `end()` run as the work that has added them, whatever work emits their event (with the receiver that Node.js gives them), and a stream that `Readable.prototype.pipe()` (also through `stream.pipeline()`) pipes into the response writes and ends it as the work that has piped it, whatever work feeds it (see [Pipes into the response](#pipes-into-the-response)). The socket itself (`request.socket`) is not guarded, and a middleware should still stop its work once `context.signal` is aborted; +- a request whose reply has not started gets a `BindenError(503, { code: "deadline" })`, which goes to the error handler (whose `context.signal` is aborted then, see [Errors](#errors); the reply gets `Connection: close` when the body of the request has not been received in full, see [Errors](#errors)). A `send()` of a stream or a `sendFile()` that has not committed the headers yet (e.g. its first chunk, or its file, has not come) is cancelled first, without waiting for it: it rejects with the reason of the signal, its stream or its file is released (a file opened later is closed; a stream is destroyed as the work that is abandoned, so that its own `destroy()` cannot change the reply either, but never the request itself, see [`.send()`](#bindenresponse)), and the headers and the status that it has set are removed; +- a reply that has committed its headers (or a response that has not finished once the stack has completed) is cut by closing the connection (one that waits for the response before it on its connection, e.g. that has only started, is cut once that response has been sent, before anything of it is: on every version of Node.js, although Node.js 24.16 and 26 would send what it has written and keep the connection open), unless it has ended while it waits for the response before it on its connection (a pipelined request): it is complete, and is sent once that response has been, on every version of Node.js (closing the connection would cut that response too), with its `onCleanup()` functions and its access log line once it has been sent (a client that goes away or a shutdown that times out still cuts it: the framework then closes it once its connection has closed, also on Node.js 24.14, which never does, and its `onCleanup()` functions and access log line run then). A deadline that passes once the stack has completed cancels a send that has not committed its headers, and replies `503` through the error handler, in the same way. That includes an error handler that is still running for another error (e.g. it hangs, or waits for the first chunk of a stream that it sends): the deadline takes the reply over at once, with a new call of the error handler for the `503`, and what the first call does later (its reply, its failure or its time out) changes nothing. + +The properties of Node.js that frame the response (`sendDate`, `shouldKeepAlive`, `chunkedEncoding`, `useChunkedEncodingByDefault`, `strictContentLength` and `maxRequestsOnConnectionReached`) are guarded in the same way: the work that is abandoned cannot change how the reply of the error handler is framed (e.g. chunk a body that has a `Content-Length`). The other fields of Node.js are its own state (e.g. `finished`, `outputData`, and those whose name starts with `_`, such as `_hasBody`), which only Node.js writes: an application must never assign them. Replacing a method of a response instance (e.g. assigning `response.setHeader`, `response.removeHeader` or `response.end`) makes that code part of the framework's own replies, which call the methods of the response: it runs with the rights of the framework (e.g. while it cancels a send, and in the `503` of a deadline), so a work that may be abandoned must not patch the response. + +`context.onCleanup(fn)` runs `fn` once the response is closed (finished, or its connection closed, also while it waits for the response before it on its connection: the framework closes it then, on every version of Node.js), 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()); +}); +``` + +#### Abandoned work + +A work that loses the response (the stack at its deadline, an error handler that is superseded by the deadline, times out or fails, or a work whose pipe fails, see below) sees its response closed, as an extension of the `close` event of Node.js: on the next tick (once what it does now has completed, e.g. `stream.pipeline()` that adds its listeners), the `error` of its failed pipe (if any) then `close` are delivered once to the listeners that this work has added to the response, and to them only. Its native pipes are unpiped, its `stream.pipeline()` and `stream.finished()` of the response settle (with `ERR_STREAM_PREMATURE_CLOSE`, or the error) and destroy the streams that they own (never the response), and its `close` cleanups run early. A `close` listener that it adds later runs on the next tick, once. At a deadline, the abort listeners of `context.signal` run as the abandoned stack, whatever has started the deadline (the `timeout` option or `context.setTimeout()`): they, and the work that they start, see the response closed in the same way. An `error` that its listeners emit again on the response is absorbed. The reply that replaces it (or the closing of the connection) starts afterwards. The observers of the framework (`onCleanup()`, the access log, the shutdown, a send that waits for its `drain`, `finish` or `close`, a compressor that sends what the response has accepted) get only the real `close`, with the final status and bytes, and `closed`, `destroyed`, `writableFinished` and `writableEnded` are not changed. They are not listeners of the response: `removeAllListeners()` and `removeListener()` of the application do not remove them (nor do `listeners()` and `listenerCount()` count them), and they run after the listeners of an event, also when one of these throws (which is then thrown, as by Node.js): e.g. `context.signal` is aborted (`client_closed`) once the `close` listeners of the application have run. + +#### Pipes into the response + +A source that `Readable.prototype.pipe()` (also through `stream.pipeline()`) pipes into the response is adopted at once, before any listener of `pipe` runs: its writes and its end run as the work that has piped it, whatever work pushes its chunks (e.g. a timer of another request). Until the end of that turn, every listener added to the source runs as the work that adds it (e.g. the end of `stream.pipeline()`, or a listener that another work adds from its listener of `pipe`, which gets no rights from the pipe): the listener-adding methods of the source are patched on the source itself for the turn, then restored. + +The class of the source (also of the `readable-stream` package, from its version 2, and of `through2`, whose pipes keep the state of Node.js 10; a write that throws out of their pipe, which does not catch it, fails the work as with Node.js) is trusted, as part of the stream that the work has chosen: that is the trust boundary, and it covers the whole prototype chain of the source, its accessors included (binden checks the own properties of the source only, see below). Piping a stream that another work has patched (its class, any prototype of its chain, e.g. one inserted with `Object.setPrototypeOf()`, with its methods or its accessors, such as a getter of `_readableState`, or its own methods, which then refuse the pipe once it is set up) runs that code as the work that pipes. A pipe is refused when code other than that class may have run while it was set up: the response or the source has listeners of `newListener` or `removeListener`, the source is a `Proxy` (refused before its inspection, which would run its traps: they run only to unpipe it, as the work that pipes, which has lost the response then), it has its own `on()`, `addListener()`, `once()`, `prependListener()`, `prependOnceListener()`, `emit()`, `pipe()`, `unpipe()`, `pause()`, `resume()`, `read()`, `rawListeners()`, `listeners()`, `listenerCount()`, `removeListener()`, `off()` or `removeAllListeners()` (other than the patches of its turn, see above; a pipe of the stack whose only methods of its own are listener methods is left to Node.js instead, see below), it has an accessor of its own (a getter or a setter, e.g. defined by another work on a shared source) of one of them or of a property that the pipe reads (`_readableState`, `_writableState`, `_events`, `_eventsCount`, `_maxListeners`, `readableEnded` or `destroyed`), it cannot be patched (e.g. `Object.seal()`), or it is not a pipe of `Readable.prototype.pipe()` (e.g. the legacy `Stream.prototype.pipe()`, which has no `unpipe()`: wrap such a stream with `new Readable().wrap(stream)`). The source is inspected and unpiped by the methods of `EventEmitter` and of its class, never by its own ones. A refused pipe fails its work with a `TypeError` at once, then is unpiped (a legacy one by its `close` listener, see above) as that work, which can no longer change the response (e.g. from a `removeListener` listener of the source; a cleanup that throws is logged at `debug`). A refused pipe of a work that the framework does not know (of no request, or of another request) is unpiped and logged at `warn`: the response is left to its owner. A legacy pipe (`Stream.prototype.pipe()`) of such a work cannot be dismantled (it has no `unpipe()`, and the framework has no work to close the response for): it is left to Node.js, logged at `warn`, and its writes are those of the work that feeds it (before the response is sealed, they reach it, as without binden). + +A pipe of the middleware stack whose source has listener methods of its own (`on()`, `addListener()`, `once()`, `prependListener()`, `prependOnceListener()`, `removeListener()`, `off()` or `removeAllListeners()`), but no other method nor accessor of its own, and which passes every other check, is not refused: e.g. a request or a client response that the `http` instrumentation of [OpenTelemetry](https://opentelemetry.io/) binds to its context (`context.bind()`), in `request.pipe(response)`, `upstream.pipe(response)` or `stream.pipeline(upstream, response)`. Such methods may add any listener during the pipe, so that nothing of it can be attributed: it is left to Node.js, as without binden, and not adopted (nothing of it gets the rights of the stack; a `debug` log names the reason). Until the stack is revoked, every work may change the response anyway. Once it is (its deadline, or the failure of a pipe of its own), and before anything replaces its reply, its source is unpiped by the method of its class, as the stack, and, unless the response has ended (its reply is complete), the response and its connection are closed instead of the `503` or `500` (a `debug` log names the reason), also once the source has ended (e.g. piped with `{ end: false }`): whatever the source says of itself, its methods may have kept or wrapped a listener of the pipe (e.g. of its end) that would write into the reply that replaces it, or end it. A write or an end of the response that throws in such a pipe is not caught either: as with Node.js, it is thrown out of the events of the source (e.g. for a head that Node.js refuses, such as a `Trailer` with a `Content-Length`, an uncaught exception that crashes the process), and does not fail the stack. To keep those replies, and the failures of the pipe, send such a stream with `context.send(stream)`, which binden reads itself (or pipe `new Readable().wrap(stream)`). The pipe of such a source by an error handler, whose rights matter, is refused (`500`): send it there. + +A pipe fails its work, as a failure of its own, when a write or the end of the response throws in it (e.g. for a head that Node.js refuses, or a chunk that is not bytes: the pipe of Node.js would destroy the response, and an end would crash the process; not a pipe of the stack left to Node.js, see above, where it is thrown as with Node.js), when it is refused, or, for a pipe of an error handler, when its source errors or closes before its end (Node.js would leave the response open, and the handler waiting), and when the error handler destroys the response while a pipe of its own is adopted, before the headers are committed (e.g. its `stream.pipeline()`, once one of its streams fails or closes before the first byte: the destroy keeps its error, or is an `ERR_STREAM_PREMATURE_CLOSE`; a destroy of the framework, of the transport, or of a work that has lost the response is not one). So is a destroy that runs as no work while the error handler replies with such a pipe (e.g. the destroyer of its `pipeline(head, transform, response)`, which runs as the work that has emitted the failure of its head, such as a timer of no request), as long as the connection is open: not once it has closed (e.g. the client has gone away), nor once the server shuts down (also in the abort listeners of the shutdown, before the connections are closed). A repeated destroy changes nothing. `stream.pipeline()` rejects with the original error, and a source that another response reads is not destroyed (a plain pipe is unpiped). A listener that a work adds to the source in the turn of a pipe (e.g. the end of its `stream.pipeline()`) fails that work when it throws, in its own request, whatever response the source is piped into and whatever work emits its event: a pipe of a work into the response of another request never changes how that request handles the failures of its own works. The stack stops, and the error handler replies to the error before the headers are sent (an opaque `500` for a failed error handler): every pending `send()` of a stream or `sendFile()` that has not committed the headers (also one that the stack has left pending) is cancelled first, as at a deadline, and rejects with the error of the pipe (whatever it is, also `null`), also one that settles otherwise in between (e.g. a `sendFile()` that sees that its work can no longer change the response is not ignored), whose headers and status are removed all the same before the error handler replies (e.g. of a `sendFile()` whose file fails in between); afterwards, the connection is closed, unless the response has ended while it waits for the response before it on its connection (a pipelined request): its reply is complete, and is sent once that response has been, on every version of Node.js, as at the deadline (the failure is still logged at `error`). `context.signal` is not aborted: the deadline still applies, and its `503` replaces the reply of the error handler that it supersedes. Before a deadline, a source of the stack that errors behaves as with Node.js (`stream.pipeline()` destroys the response). + +The work of an abandoned middleware keeps the async context of whoever runs it where binden does not see it: a listener added to a piped source after the turn of its pipe, and the listeners of a stream that is not piped into the response (e.g. the `error` listener of `stream.pipeline()` on the head of `pipeline(head, transform, response)`), run as the work that emits their events, as `AsyncLocalStorage` does, while the callbacks of `stream.finished()`, and so the end of a `stream.pipeline()` (e.g. its destroy of the response once its head has closed early), run as the work that has called it, as Node.js binds them. An error handler should not drive (end or destroy) the streams of the work that it replaces: they belong to the abandoned work. + +`response.rawListeners()` has the listeners themselves (the wrapper of a `once()` listener, whose `listener` is the original, which removes itself when it is called), `response.listeners()` and the `newListener` and `removeListener` events the listeners as added, and each listener and callback gets the receiver that Node.js gives it. + +### 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, and the idle connections are closed (and every other one as soon as it is idle). The newest response of every connection gets `Connection: close` when its headers are sent (the pipelined requests before it get their responses first). A request that still arrives on an open connection gets `503 Service Unavailable` with `Connection: close`, without running the stack (and the responses before it keep the connection open for it); +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. A server whose `listen()` has not completed yet (e.g. while the address of a host name is looked up) is closed once it listens, within the same `timeout`; when its `listen()` fails, the promise rejects with that error (and logs it), and when it takes longer than `timeout`, it is cancelled (the server does not listen once the address has been looked up) and the promise rejects. + +The server must be registered before its `listen()`: create it with `createServer()` or `createSecureServer()`, or call `callback(server)`, first. A `listen()` called before is not known to `shutdown()`, which may then resolve before the server listens, and leave it listening. + +```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: 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; +log.info("Hello World", { data: 100 }); +``` + +- `.setHeader()` - Set a response header + +```typescript +const name = "X-HEADER"; +const value = ["value1", "value2"]; +context.setHeader(name, value); +``` + +- `.status()` - set the response status + +```typescript +context.status(401); +``` + +- `.request` - get the original request object (`instanceof BindenRequest`) + +```typescript +const { request } = context; +``` + +- `.response` - get the original response object (`instanceof BindenResponse`) + +```typescript +const { response } = context; +``` + +- `.id` - get `id` of the context (generated by the [randomUUID()](https://nodejs.org/dist/latest/docs/api/crypto.html#crypto_crypto_randomuuid_options) function and logged as `trace_id` by the `context.log`) + +```typescript +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 +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 + } +} +``` + +or with a function + +```typescript +import type { Context } from "binden"; + +const MyMiddleware = (context: Context): void => { + context.done = true; +}; +``` + +- `.url` - parsed `URL` object + +```typescript +const { + log, + url: { search }, +} = context; +log.trace("url search string", { search }); +``` + +- `.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!"; +await context.send(data); +// or +await context.response.send(data); +context.done = true; +``` + +- `.json()` - execute `context.response.json()` (any JSON value) and set `context.done` to `true` + +```typescript +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](https://github.com/fastify/fast-json-stringify)) can be passed as the second argument. + +```typescript +const { default: 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` + +```typescript +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` + +```typescript +const 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` + +```typescript +const form = new URLSearchParams({ a: "1" }); +await context.form(form); +// or +await context.response.form(form); +context.done = 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, { max_age: 3600 }); +// or +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 +context.throw(402, { json: { error: "Payment Required" }, expose: true }); +``` + +### Middleware + +Any middleware should be extended from the abstract `Middleware` class and implement the `.run()` method + +```typescript +import { randomInt } from "crypto"; +import { Middleware, Context } from "binden"; + +export class MyMiddleware extends Middleware { + public async run(context: Context): Promise { + 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 + +```typescript +import { Middleware, Context } from "binden"; + +export class MyMiddleware1 extends Middleware { + public run(context: Context): Promise { + return context.json({ message: "Hello World" }); + } +} + +const mm1 = new MyMiddleware1({ disabled: true }); + +// 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)` + +```typescript +import { Middleware, Context } from "binden"; + +export class MyMiddleware1 extends Middleware { + public run(context: Context): Promise { + if (this.ignore_errors) { + return context.json({ message: "Hello World" }); + } + context.throw(400); + } +} + +const mm1 = new MyMiddleware1({ ignore_errors: true }); + +// 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: a context of binden, told by its private brand (an instance of `Context` or of a subclass, never an object derived from one, e.g. with `Object.create()`, nor a proxy of one, which is ignored as any other value that is not a context), with 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, 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`, 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. +- `OPTIONS *` (the server as a whole) gets `204 No Content` without running the stack, with an `Allow` that lists the methods of every router of the application (in every stack, with `HEAD` for `GET` when `auto_head` is `true`, and `OPTIONS`). + +```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, 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 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 reason phrase of that status (a `statusMessage` set for the reply that it replaces is dropped), the `headers` of a `BindenError` and its exposed `json` or `message`. The headers set for the body that is not sent (its framing, representation and validators, e.g. `Content-Type`, `Content-Language`, `Content-Length`, `Content-Encoding`, `ETag` or `Trailer`) are removed, so that an exposed `message` is never sent as the type of the route (e.g. as HTML), and the cookies of the response are sent. The reply closes the connection (`Connection: close`) when the body of the request has not been received in full when the reply is sent (`request.complete` is `false`), whether a middleware has read a part of it or nothing has read it: Node.js does not read the rest of a body that has been read in part (the next request of the connection would wait for it), and binden never reads a body that nobody wants (e.g. a large upload refused at once). The rule is conservative: a request that fails before Node.js has parsed its whole body loses its connection, even for a small body that has already come, e.g. a `POST` that a middleware refuses synchronously (`401`, `403`, `415`) while its body is still unparsed in the same packet as its headers. A body that has been received in full (read or not) keeps the connection open; +- sends the cookies of the response, each serialised once in its own guard. A cookie that fails then is dropped from `response.cookies` and logged at `warn` ("A cookie has not been sent with the error reply"), also one that has not failed before. It fails when its `toString()` throws (e.g. of a revoked proxy or of an object derived from a `Cookie`) or when Node.js refuses its value (e.g. with a line break). The error is replied with the other cookies, framed by its `Content-Length`, so that a pipelined request that follows is answered; +- 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, and with `Connection: close` when the body of the request has not been received in full, as the default reply; its cookies are sent as by the default handler: each that fails is dropped and logged), or the connection is closed when its reply has committed its headers; a reply that it has ended is left to finish (e.g. still flushed to a slow client, also through a compressor). Every pending `send()` of a stream or `sendFile()` that has not committed the headers yet (e.g. whose first chunk, or whose file, has not come), of the handler or one that the stack has left pending (not awaited), is cancelled first, as at a deadline: it rejects with the failure of the handler (whatever was thrown, also `null`; a `BindenError(500, { code: "error_handler_timeout" })` once it has timed out), also one that settles otherwise in between (e.g. a `sendFile()` that sees that its work can no longer change the response is not ignored), its stream or its file is released (a file opened later is closed), the headers and the status that it has set are removed (also for one that has settled in between), and the `500` replaces it. From then on, it can no longer change the response (as the middlewares once the deadline has passed, see [Lifecycle](#lifecycle)): 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 }); + }, +}); +``` + +After a deadline, the error handler gets the `BindenError(503, { code: "deadline" })` with `context.signal` already aborted: work bound to the signal (e.g. a `fetch()` or a rendering that is given `context.signal`) fails at once, and the request then gets an empty `500` instead. Check the error, and reply without such work: + +```typescript +import { Binden, BindenError, type Context } from "binden"; + +const app = new Binden({ + timeout: 30_000, + error_handler: async (context: Context, error: unknown): Promise => { + if (error instanceof BindenError && error.code === "deadline") { + // `context.signal` is aborted: nothing that depends on it + await context.status(503).json({ error: "timeout" }); + return; + } + 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`, and one out of this range a `RangeError`). Its options: + +- `expose` (a boolean, `false` by default) - whether the default error handler sends `json` (as `application/json`) or `message` (as `text/plain; charset=utf-8`); otherwise the reply is empty; +- `json` (a plain object), `message` (a string, by default the reason phrase of the status) and `cause`: another `expose`, `json` or `message` throws a `TypeError`. `json` is copied once (a shallow copy) by the constructor, which reads it then: what a getter of it throws, the constructor throws, formatted. A later change of the object given changes nothing, and `error.json` returns a copy of that copy, which runs no code of the application; +- `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`. + +Its stack is formatted in its constructor, unless it is an instance of a subclass, which keeps the header that its own constructor gives it (see [Lifecycle](#lifecycle)). + +```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"; + +export class MyMiddleware extends Middleware { + public async run(context: Context): Promise { + 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` }); + } +} +``` + +### BindenRequest + +Simple usage with `http` + +```typescript +import { createServer } from "http"; +import { BindenRequest } from "binden"; +const server = createServer({ IncomingMessage: BindenRequest }); +``` + +- `.header()` - get a header by name + +```typescript +const rawHeader = request.header("X-Header"); +``` + +- `.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; +``` + +- `.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; +if (protocol !== "https:") { + console.error("The connection is not secure"); +} +``` + +- `.secure` - same as `request.protocol === "https:"` + +```typescript +const { secure } = request; +if (!secure) { + console.error("The connection is not secure"); +} +``` + +- `.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, a space or a control character, 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, with at most `query.max_keys` keys (1000 by default, the others are ignored) + +```typescript +import { parse } from "node:querystring"; + +const { query } = request; +// same as +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. The parameters of a media range must be those of the offer: their names are case-insensitive, and so is the value of `charset`, but the other values are compared as they are + +```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. Every field line of `Forwarded` is read from its end, where the proxies append their elements: a quoted string that a client leaves unterminated invalidates its own element (and those before it), never the elements appended after it. 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. Without the `request_id` option, the id is not sent back. With it (even `request_id: {}`, which sends it back with the defaults), 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`, once `request_id` is set) - 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"; + +// 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`: 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"; + +const app = new Binden({ encoded_slashes: true }); +``` + +### BindenResponse + +Simple usage with `http` + +```typescript +import { createServer } from "http"; +import { BindenResponse } from "binden"; +const server = createServer({ ServerResponse: BindenResponse }); +``` + +- `.started` - whether the response has been started: by `.send()` or `.sendFile()` (until they fail before sending anything, or are cancelled: by a deadline, by a failure or a time out of the error handler, or by a failed pipe of the stack or of the error handler, each of which cancels every pending send that has not committed the headers, also one that the stack has left pending), by a stream piped into it (until a deadline abandons it, or its pipe fails, before it has written anything; never by a pipe that is refused, which writes nothing), or by its headers being sent + +- `writeHead()` checks the reason phrase that it would send and the headers that it is given before it changes the response: one that Node.js refuses throws, and the status, the reason phrase and the headers are left as they were (Node.js would have set them first). `writeHead()` (also `writeHeader()`, and the commit by a first `write()`, `end()` or `flushHeaders()`) reads its arguments once, and Node.js gets exactly what binden has read and checked: the status code, converted once as Node.js converts it (an invalid one throws `ERR_HTTP_INVALID_STATUS_CODE`, the value given in its message as Node.js formats it, before anything else is read), and copies of the headers given (an array by its indexes, its pairs and the values of a header as well, an object by its own enumerable properties), so that a getter or a proxy of the headers runs once. binden's own checks convert nothing of the application: a header name that is not a string and a value of another type (an object, or an array with one) are checked, and converted, by Node.js itself, as without binden (e.g. `ERR_INVALID_ARG_TYPE` or `ERR_INVALID_HTTP_TOKEN` for a name), so that binden makes as many conversions as plain `node:http`. A `statusCode` that is not a number (e.g. an object assigned to it, or a string) is converted once in the same way before binden decides anything by it. A `statusCode` that is a number but not an integer is the integer that Node.js commits (`| 0`), and binden decides by that integer as well: `204.5` is a `204`, without a body nor a `Content-Length`, and `205.5` a `205` with `Content-Length: 0`. One that is out of range once truncated (e.g. `99.5` or `1000`) is left as it is, and the commit of the head refuses it, by the value given (`ERR_HTTP_INVALID_STATUS_CODE`, "Invalid status code: 99.5"), before the encoding of a chunk is checked: `write("x", "bogus")` with a `statusCode` of `1000` throws that error, as with plain `node:http`, also through @binden/compression. A head that Node.js commits by the status of the response itself stores the integer that it commits before it checks the reason phrase (or the encoding of a chunk), as Node.js does: a first `write()`, also of a response without a body (e.g. to `HEAD`), an `end()` with or without a chunk (also `end("")`, `end(callback)` and `end(null)`), `flushHeaders()`, and `writeHead()` given the `statusCode` itself. Once a reason phrase is refused, `statusCode` reads back as that integer (also of a string, or of a fractional number: `204.5` is then `204`), as with plain `node:http`, and a retry does not convert it again; also through @binden/compression. A `write()` or an `end()` of a chunk that Node.js ignores (of a destroyed response, or of one that has ended without its head) neither converts nor refuses the status. A status given to `writeHead()` is converted by that call alone, and a refused call leaves the status of the response as it was. This holds on every path that commits the head implicitly: a first `write()`, an `end()` with or without a chunk, `flushHeaders()`, `.send()`, `.text()`, `.html()`, `.json()`, `.form()` and `.sendFile()`, also through @binden/compression. binden decides by that number (e.g. whether the response has a body, or whether the preconditions of a file apply). It becomes the `statusCode`, as Node.js sets it once it has converted it, and Node.js commits exactly that number, so that it is converted as often as by plain `node:http`. One that the conversion refuses (or whose conversion throws) makes the call throw, or `.send()` and `.sendFile()` reject (the source of a send released), before anything is changed. The `statusCode` then stays as it was assigned, as one assigned once the head is committed does. Likewise `writeEarlyHints()` reads its hints once (their `link`, then, unless there is none, their other own enumerable hints), and `addTrailers()` its trailers once (each pair by its name and its value, each array of values by its indexes). A reason phrase that Node.js refuses once it sends the headers (e.g. a `statusMessage` assigned before `.send()`, or a `Trailer` of a body that is not chunked) makes `.send()`, `.sendFile()` and `.flush()` reject before anything is sent, so that the error can still be replied (with the reason phrase of its own status). A send of a value (`.send()` of a string, a number, bytes or no body, `.text()`, `.html()`, `.json()` and `.form()`) whose head Node.js refuses (a status out of range, a reason phrase, a `Trailer` of a body that is not chunked) leaves the response as it was before the send, as a refused `writeHead()` does: its status, its reason phrase, its headers (without the `Content-Type`, the `Content-Length` and the dynamic `ETag` that it has set, nor the status of a `304`, `406` or `412` that has replaced its body) and the framing fields of Node.js. As after a failed send of a stream or a file, a `Content-Length` or a `Transfer-Encoding` set before the send (whose body it was) is removed, and the representation chosen for the body (e.g. by @binden/compression) is forgotten, so that the reply that follows frames its own body. `.send()` (also `.text()`, `.html()`, `.json()` and `.form()`) and the default error reply set their own `Content-Length`, whatever was set before, so that a pipelined request that follows is answered. A reply of `write()` and `end()`, which sets no length of its own, is framed as with plain `node:http` when nothing had framed the response before the send (e.g. by the `Content-Length` that Node.js gives a single `end(chunk)`); chunked once a `Content-Length` set before has been removed (also a single `end(chunk)`); and once a `Transfer-Encoding` set before has been removed, by its length when Node.js knows it (a single `end(chunk)`), otherwise by the close of the connection (a pipelined request that follows is then not answered). A head that Node.js refuses once it has started to frame it (by `writeHead()`, a first `write()`, `end()` or `flushHeaders()`, e.g. a `Trailer` of a body that is not chunked, once it has decided to close the connection) leaves the response as it was before the attempt: its status, reason phrase and headers (their names, order and values), and the framing fields of Node.js (`_contentLength`, `_defaultKeepAlive`, `_hasBody`, `_keepAliveTimeout`, `_last`, `_removedConnection`, `_removedContLen`, `_removedTE`, `chunkedEncoding`, `maxRequestsOnConnectionReached`, `sendDate`, `shouldKeepAlive`, `strictContentLength` and `useChunkedEncodingByDefault`), and the bytes of the body that Node.js has counted for `strictContentLength` (it counts a chunk before it commits the head), so that the reply that follows (e.g. of the error) is framed as if the head had never been attempted, and keeps the connection alive when it may. What has happened otherwise (its listeners, and what has been written) is not undone, but an `end()` that throws for such a head uncorks the socket back to what it was (Node.js corks it first). A `write()` or an `end()` that Node.js throws from once the head is committed (before the call or by it, e.g. a body longer or shorter than the `Content-Length` that `strictContentLength` checks) sends nothing more: the response is destroyed at once, with the rights of the framework, before the error is thrown, so that what the socket holds is never flushed (an `end()` that commits the head has written it, with its body, into the socket that Node.js has corked: the client would read the excess of the body as the next response of the connection), a response that waits for the one before it is cut once its socket comes, and the connection is closed, without a reply of the error. So is a later `write()` past a strict `Content-Length`, unlike Node.js, which refuses that chunk before writing anything of it and keeps the response usable: only the throws below keep it. But a throw that leaves what is queued valid leaves the response usable, as Node.js does: `ERR_HTTP_BODY_NOT_ALLOWED` (a body written to `HEAD`, or with a 204 or a 304 status, under the `rejectNonStandardBodyWrites` option of the server: Node.js has generated the head, and queued nothing more, so that an `end()` that follows sends the reply complete, without a body), and the checks of a chunk before Node.js takes it (`ERR_INVALID_ARG_TYPE`, `ERR_STREAM_NULL_VALUES` and `ERR_UNKNOWN_ENCODING`). The bytes of an earlier write that has been flushed (e.g. by `flush()`) cannot be recalled. Node.js checks `strictContentLength` only for the chunks that follow the head (it reads the length from the head that it generates), and would write the whole first chunk, whose excess the client would read as the next response of the connection: a first `write()` past a valid `Content-Length` (of a body that is not chunked, as Node.js checks the others) throws its error (`ERR_HTTP_CONTENT_LENGTH_MISMATCH`) before Node.js gets it, so that nothing is committed, and the error can still be replied. Node.js sends a line for each element of an array `Content-Length` (set by `setHeader()`, by two `appendHeader()` calls, or given to `writeHead()`) and frames the body by the last one, which a recipient may read otherwise: elements that are not all one same length of digits make the call that would commit the head (`writeHead()`, a first `write()`, `end()` or `flushHeaders()`) throw a `TypeError` (`ERR_HTTP_INVALID_HEADER_VALUE`, "Content-Length values disagree") before anything is committed, so that the error can still be replied (the values given to `writeHead()` are checked in place of the ones set, which they replace). Node.js cannot read a comma list (it does not know the length of the body): any list is refused the same way, even one whose members agree (`2, 3`, but also `2, 2` and `2,2`, alone or as an element of an array), with "Content-Length is a list". Equal elements are that length (also for the first `write()` under `strictContentLength`); `send()` of a value replaces them (or a list) with its own length. A `Content-Length` that is neither a number nor a string (nor an array of them, each element read by its index: also an element of another type, or a hole), e.g. `true`, `null`, a `bigint` or an object whose `toString()` gives a length, which `setHeader()` of Node.js accepts (converting it once as it checks it) and would send converted, is refused type-first, never converted: the call that would commit the head (`writeHead()`, a first `write()`, also under `strictContentLength`, `end()` or `flushHeaders()`) throws a `TypeError` (`ERR_HTTP_INVALID_HEADER_VALUE`, "Invalid Content-Length: …", the value described, e.g. `(an object)`, or `undefined` for a hole) before anything is committed, and `.send()` of a stream (a `Readable` or a stream-like source, also to `HEAD`), or of no body to `HEAD`, rejects with it before anything is sent: its source is released, the head is not committed, and the error can still be replied. `.send()` of a value and `.sendFile()` replace it with their own length. @binden/compression refuses it the same way before it decides. A chunk that is not one and an unknown encoding throw before anything is written, and leave the response as it is. When the reply of the framework itself cannot be sent (e.g. its `end()` throws once the head is committed), the connection is closed. + +- `.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 (also by its alias `writeHeader()`, which Node.js 24 has, and which the response keeps on every version), `write()`, `end()`, `flushHeaders()` or a piped stream), after the `Set-Cookie` values set on the response or given to `writeHead()`. `response.cookies.add()` refuses anything but an object (a `Cookie`, or an object whose `toString()` gives the value of its `Set-Cookie`, read as the head is committed) with a `TypeError` that describes it (e.g. "`cookie` is not an object: null"), formatted, before it adds it. A sealed response ignores it. A cookie that fails as the head is committed fails that commit: the call that commits it throws its error, formatted (a send rejects with it), and nothing is sent. An error reply of binden drops it (see [Errors](#errors)). + +```typescript +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"); +``` + +- `.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 +await response.status(400).send(); +``` + +- `.set()` - Set the headers (the own properties of the object) + +```typescript +const headers = { + "X-AMOUNT": "100.02 USD", + "X-MONTHS": ["jan", "feb"], +}; +await response.status(402).set(headers).send("Payment is required"); +``` + +- `.type()` - set the `Content-Type`: a media type with its parameters, each a token and a token or a quoted string (`name=value` or `name="value"`), with optional whitespace (a `TypeError` otherwise, e.g. for `text/plain; charset` or `text/plain; a=b c`) + +- `.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), 204 and 304 responses have none (but the `304` of the dynamic `ETag` and of `.sendFile()`, which has the length of the `200` that it stands for), and 205 has `Content-Length: 0`. A string is encoded once (in its `encoding`, UTF-8 by default): those bytes are sent, and give the `Content-Length` and the dynamic `ETag`. A response without a body (to `HEAD`, or with a 204, 205 or 304 status) sends none: a `Readable` (or a stream-like source) is destroyed without being read, and a response to `HEAD` gets the headers of the body it would send to `GET` (`Content-Length: 0` without a body, unless a valid `Content-Length` is set: the length of the body of `GET`), but no `Trailer` (also from `.sendFile()`): Node.js refuses one without a chunked body, and RFC 9110 (section 9.3.2) lets `HEAD` omit the fields that only generating the content determines. The default `Content-Type` (`text/plain`) is set for a body (also an empty string) of a status that has one. A 1xx status is not of a final response: `send()` rejects with a `TypeError` (send an informational response with `writeEarlyHints()`), and destroys a stream without reading it. A `send()` that is ignored (once the response has ended, or by a work that may no longer change it, see [Lifecycle](#lifecycle), also through `context.send()`) resolves without reading anything of its data (not even the methods of an object), but a Node.js `Readable` (whose chain of prototypes reaches the `Readable.prototype` of Node.js, without a `Proxy` anywhere in it, read link by link without running any code of it), e.g. the stream of a file that would stay open, is released without being read: it is destroyed by `Readable.prototype.destroy()`, never through its own `destroy()` (its `_destroy()` runs), under a guard of its own (see below), and a failure of that release is logged at `debug`. Any other stream (a stream-like source, or a `Proxy`) is neither read nor released, which is left to the work that has it. 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 (with the error that it has been destroyed with, its `errored`, when it emits no `error`), or does not have its `Content-Length`, which is read once before its first chunk is written (also when a compression removes the header then) and frames it without a stale `Transfer-Encoding` (as for a response to `HEAD` that keeps a valid `Content-Length`). When the client goes away (or the response is closed otherwise) before the send has settled, the send rejects with the error of the connection when a write fails first (e.g. `EPIPE`, `ECONNRESET` or `ECANCELED`: Node.js and the system decide which comes first), otherwise it settles at once when the response closes: it rejects as a premature close (`ERR_STREAM_PREMATURE_CLOSE`), or resolves when its body is complete for the client. A body of a declared `Content-Length: 0` is complete only once the response has finished: a client that goes away before (e.g. before it has the head) rejects the send. Only then is its source released, and a release never decides the send: one that fails is logged at `debug`. A `destroy()` or a `pause()` of a stream-like source that throws while it is released is logged at `debug`, and the send settles with the error that has decided it (a response to `HEAD`, with 204 or 304, still gets its reply, and a send for a 1xx status its `TypeError`). A failure that `send()` finds itself (a body longer than its `Content-Length`, a chunk that is not bytes, or any code of the source that throws: its own `on()`, also a listener of its `newListener`, and its `resume()` at any time, also the one that `on("data")` of Node.js calls for a source that is not paused, and the one once the response drains) rejects at once, with what has been thrown as it is (for a Node.js `Readable`, also `null` or `undefined`; a falsy value thrown by the code of a stream-like source rejects as a premature close, `ERR_STREAM_PREMATURE_CLOSE`, since Node.js drops a falsy error of the `Readable` that reads it; the source is destroyed with a premature close of its own, which is not logged when it emits it), and only then destroys the source with that error (see below): a slow `_destroy()` does not delay the reply, and nothing of the send is left (no listener of it, and `.started` is `false` again before the headers are committed). A Node.js `Readable` is destroyed so by one call of its `destroy()`: with that error itself when Node.js can read its `stack` without running code of the application (e.g. a `stack` that is a value, none, or the engine's own getter of a native error, subclasses and `DOMException` included, which binden reads first, and checks again once it has read it: reading it may run code of the application, e.g. a getter of its `name` that V8 reads as it formats it, or a `prepareStackTrace`, that redefines its `stack`, so that the value is used only when Node.js reads it as checked), and otherwise (e.g. a getter of the application, a proxy, or a `prepareStackTrace` that fails to format it, any of which could leave the stream undestroyed) with an `Error` that carries it as its `cause` (an object of `Error.prototype`, not a native error, whose `stack` is a value of its own, "Error: A stream has failed", that can be neither written nor redefined: no `prepareStackTrace` runs for it, it holds no frames, and Node.js reads its stack as it is, also when a `_destroy()` of the application tries to redefine it), which is not logged, so that its file, for example, is closed in any case; the send still rejects with the error itself. The `destroy()` of Node.js itself (`Readable.prototype.destroy()`, of a stream that does not replace it) that throws before it has destroyed the stream (e.g. when the `stack` of the error has become a getter that throws once checked) is called once more, with that carrier. A `destroy()` of the stream's own that throws is logged at `debug` ("The release of a stream has failed") and never called again (it may have done anything before it threw), and the send still rejects with the error itself. A value that a stream-like source brings and whose `stack` Node.js cannot read safely (a `stack` getter of the application, also one that returns a string when it is first read and throws once it is read again, a proxy, or a `prepareStackTrace` that fails to format it) follows the rule of a `Readable`: the readable that `send()` reads the source through is destroyed once, with that value itself when Node.js can read its `stack` safely, and otherwise with an `Error` that carries it as its `cause` ("A stream has failed", an error of binden that is not logged, made as the carrier of a `Readable` is (its `stack` a value of its own that no `prepareStackTrace` runs for)), so that the send settles at once, and still rejects with the value itself. So it is when code of the application redefines that `stack` once binden has checked it (a getter of its `name`, which V8 reads while it formats the stack, a `prepareStackTrace`, or the source's own `destroy()`, given that value): the readable is destroyed with the carrier then, and the `destroy()` of the source is still called once (what it throws is logged at `debug`, "The release of a stream has failed", and it is not called again). Only the send whose readable has made a carrier takes it for its value: a carrier that the application keeps (e.g. from the `destroy()` of a source) and that another stream emits is that stream's own error, which its send rejects with. When code of the source throws the value (its `on()`, also a listener of its `newListener`, its `pause()`, its `resume()`, the first one or once the readable has room again, or a getter of its `destroyed` read at its `end` or its `close`), the source is released with that same value, or its carrier, by one call of its `destroy()`, or paused when it has none; when the source emits the value as its `error`, it is released without that error (destroyed, or paused when it has no `destroy()`) as soon as the send hears it, before the send rejects. When a getter of its `destroyed` throws at its `end` or its `close`, the send rejects with what the getter threw: another error that the source emits while its guard is installed (e.g. from a listener of its `newListener`) is reported once as a late error ("A released stream has failed"). A `Readable` that has already ended is an empty body, and one that has been destroyed or has errored before (`errored`, e.g. a stream without `autoDestroy`, which is not destroyed) rejects at once (with its `errored`, otherwise `ERR_STREAM_PREMATURE_CLOSE`; that error is decided before its guard is installed, so that it is not logged when code of the stream emits it, or throws it again, meanwhile, e.g. a listener of its `newListener`), and closes a response whose headers are committed; before they are, the framing of its body is removed, so that a reply that follows (e.g. `end()` in the work that catches the rejection) frames its own body, and one that has errored without being destroyed is then destroyed, so that what it holds (e.g. a file) is freed. A `Readable` that is paused (by `pause()`, or by an `unpipe()`, e.g. the request that a body parser has refused) is resumed, as `Readable.prototype.pipe()` does: once the response drains when it needs to. Its listeners are added by its own `on()`, the one of `error` first (nothing of the stream runs, e.g. the `resume()` that `on("data")` of Node.js calls, which emits `resume`, before a listener of its `error` is in place), and removed, once the send has settled, by its own `removeListener()` (or `off()`, or else `EventEmitter.prototype.removeListener()`), so that an instrumentation that wraps them (e.g. the `context.bind()` of OpenTelemetry) releases them too; a removal that throws is logged at `debug`. A stream that is not a Node.js `Readable` (`IStreamLike`, e.g. of `readable-stream` 2, 3 or 4, of `through2`, or of another realm: an object with `on()`, `pause()`, `resume()`, and `removeListener()` or `off()`) is sent as one: it is read with backpressure (paused while the response is full, and resumed once it drains), a `data` event that is neither a string nor bytes (e.g. `null`, which is never taken as its end) rejects with a `TypeError` (`ERR_INVALID_ARG_TYPE`), and one whose chunk the stream cannot take (e.g. a view of an `ArrayBuffer` that has been detached) rejects with what Node.js throws for it, never thrown into the `data` event of the stream, its `error` or a `close` before its `end` rejects, and a cancellation (the deadline, a client that goes away) destroys it with the reason (`destroy(reason)`, a reason that is not logged when the stream emits it, and whose stack is formatted, so that a stream that keeps the error it is destroyed with, e.g. minipass, keeps nothing of the request or of the response), or pauses one that has no `destroy()`; once its send has resolved (another writer, e.g. a compression with its `406`, has ended the response, or its declared `Content-Length` is complete when the client closes), it is destroyed without an error, as a `Readable` is (unlike `pipeline()` of Node.js, which destroys it with a premature close), so that its own listeners of `error` and its `errored` tell no failure of a successful response. Its listeners are added by its own `on()`, each recorded before that `on()` runs: code of it that throws while they are added (its `on()`, also a listener of its `newListener`), or in its `pause()`, rejects at once with what has been thrown, removes what has been added, and destroys the source with that error (or pauses one without `destroy()`), which is not logged again when it is an object (nor when its code throws it again for the listener of the guard); a removal that throws (its own `removeListener()` or `off()`, also the deferred one of `error`) is logged at `debug` with the messages of a Node.js `Readable` ("The removal of a listener of a stream has failed"; a refused guard, or a `pause()` or `destroy()` that throws while it is released: "The release of a stream has failed"), not as a late error, and the send no longer hears the source then, also when the removal has thrown after it has removed its listener. A listener that a failed removal (its own `removeListener()` or `off()`) leaves on a stream-like source holds nothing of the request or of the response, and does nothing once the send has settled (one of `error` left that way keeps logging later errors); a Node.js `Readable` keeps its own listeners, which are the application's. A logger of the application that throws for one of these records (e.g. its own `debug()`) stops nothing of the release of a stream-like source: its listeners are removed, it is destroyed (or paused) all the same, and the send settles with the error that has decided it. One that has failed without being destroyed (its `errored`, or that of its state, e.g. `readable-stream` 4 with `autoDestroy: false`) rejects at once with that error, and is released with it, as a `Readable` in that state is. One that emits its `error` while it is sent without being destroyed (e.g. `autoDestroy: false`) is released (destroyed without that error, or paused without `destroy()`) as soon as the send hears it, before the send rejects, as a Node.js `Readable` is. An `end` or a `close` that it emits once it has been destroyed (`destroyed`, or the `_destroyed` of `through2`; e.g. `readable-stream` 2 emits both before the error of its `destroy()`) is not its end: the error that it emits within the turn rejects, `ERR_STREAM_PREMATURE_CLOSE` otherwise (replied when nothing has been sent and the headers are not committed, e.g. by `flushHeaders()` or `writeHead()`; otherwise the connection is closed, so that a truncated body never looks complete). None of that code throws into the events of the source or out of a turn: the send rejects with what it throws, and a `destroyed` getter that throws at its `end` or its `close` rejects the send instead of ending it. Every listener that `send()` adds to it is removed (by its `removeListener()` or `off()`) once it has ended, failed or closed (the `error` one on the next turn, so that an error emitted after its `end` or its `close` does not crash the process), and at once when the send is cancelled, whatever its `destroy()` emits (it may emit nothing). A stream that `send()` releases (to a response without a body, for an informational status, or once the send is ignored, see above) or cancels (destroyed, or paused when it has no `destroy()`), and one that the application has destroyed (before the send, or once sent: its `end` or `close` once destroyed), gets a guard of its own, one however many sends release it, installed before the stream is paused or destroyed and before `send()` removes its own `error` listener, which logs a late error (e.g. of a `_destroy()` that fails seconds later, or of a `pause()`) at `debug` rather than letting it crash the process; an error that the stream emits while its guard is installed (e.g. by a listener of its `newListener`) is a late one, logged once, never taken by the send that has settled, unless it is the error that decides the send (an object: the `error` that the stream has emitted while it was sent, the `errored` that it has been destroyed with before the send, or what its code has thrown), which is decided before the guard is installed, and is neither logged as a late error nor as a refusal of the guard, also when code of the stream sends it again meanwhile (e.g. from its own `destroy()`); while a send listens to a stream, it hears its errors: an error that fails the send (also one of a stream that another response has released, e.g. one `Readable` sent to `GET` and released to `HEAD`) (its `error`, or the `errored` that its `close` brings when its `_destroy()` has swallowed the error) has decided it and is not logged by any guard of that stream (the same object of the application, e.g. one error that it destroys each of its streams with, is still logged once as a late error of another stream); once the response has ended, the send logs it once itself, on a later turn, once the event that has brought it has reached every listener of the stream: a live send of the same stream (e.g. of another response) that rejects with it has decided it then, and it is not logged, whichever send listens first; an error that decides the send (also the one that `send()` destroys the source with itself: an error of binden is not logged for any stream: e.g. for a body longer than its `Content-Length`, for a `data` event of a stream-like source that is neither a string nor bytes (`ERR_INVALID_ARG_TYPE`), for its close before its end (`ERR_STREAM_PREMATURE_CLOSE`), or the `Error` that carries a value whose `stack` Node.js cannot read safely ("A stream has failed", of a stream-like source as of a Node.js `Readable`), also when the application destroys another stream with it; a value that code of the stream throws, e.g. its `resume()`, its `pause()` or its `on()`, is an object of the application, not logged for that stream only) is not logged again when it is an object (only an object can be recorded as decided: a thrown primitive, e.g. a string, is logged once at `debug` when the source emits it (for a stream-like source as for a `Readable`)), and one that comes once the send is decided is logged once. The guard holds nothing of the request or of the response (only the report of a late error), and is removed on the turn after the `close` of the stream only when its state tells that it has closed (`closeEmitted`, e.g. a Node.js `Readable` or `readable-stream` 4; on the next turn when it has closed already), never by a timer. Each of its two listeners is removed on its own, and a removal that throws (e.g. a listener of `removeListener` of the application) is logged at `debug` as "The removal of a listener of a stream has failed", never as a late error. A stream whose state cannot tell it (`readable-stream` 2 and 3, `through2`, an emitter of its own) may still emit an error after its `close` (e.g. a write of the application once binden has destroyed it at the deadline or once the client has gone away, or a second `destroy(error)`), and keeps its guard as long as it lives, as does a stream that never closes: each later error is logged once at `debug`. A stream may refuse a listener of its guard (its own `on()`, or a listener of its `newListener`, throws for it): the refusal is logged once at `debug` as "The release of a stream has failed", but never with an object that has decided the send (e.g. the same object thrown for the listener of the send, which rejects with it; a primitive is logged). One that refuses the listener of `error` of its guard keeps the listener of `error` of its send instead, which logs its later errors at `debug` and holds nothing of the request or of the response once the send has settled, and is then destroyed without the error that has decided the send (the send rejects with it). One that refuses the listener of `close` of its guard keeps the listener of `error` of the guard (the listener of `error` of its send is removed) for as long as it lives, since the guard no longer hears its `close`: it logs its later errors at `debug`, and the stream is still destroyed with the error that has decided the send, as a guarded one is. One that refuses every listener of `error` (also the one of its send) is left untouched, as its destroy or its pause could fail (also asynchronously) without a listener: binden calls neither its `destroy()` nor its own `pause()` nor `Readable.prototype.pause()` (whose `pause` event a listener of the application could fail it by later), a `debug` record tells that it could not be released, and it is the application's to release. A late error of a source is logged once at `debug`, whatever reports it (its guard, the listener of its send that it keeps, or the send once the response has ended, also when code of a stream-like source ends the response while `send()` reads it, e.g. a getter of its `errored` or its own `on()`), and never when it has decided a send (e.g. a body longer than its `Content-Length`, or the error that a destroyed or errored source rejects the send with at once). It is logged on a later turn (by `setImmediate()`), once the event that has brought it has reached every listener of the stream, and only when no send of the stream has decided it by then: a live send of the same stream (e.g. of another response) that rejects with it has decided it, whichever send listens first and whatever reports it, and so has the send of a stream-like source that emits that error again (e.g. twice, or from the `destroy()` that releases it, or its `pause()` when it has no `destroy()`), and the error is dropped as soon as it is emitted again then, even before the send has rejected with it; until then, its report holds the stream and the error, and nothing of the request or of the response (also the readable that `send()` reads a stream-like source through, which such a report may hold as its stream: once destroyed, it holds nothing of the response); a logger that throws for that record on that turn is ignored. A value that code of a stream-like source throws is decided only once the send rejects with it, as is an error that the source emits: one that a recorded cancellation displaces (the send rejects with the reason of the cancellation, see below), or that its code throws once the response has ended (e.g. a `pause()` that ends the response before it throws), is logged once at `debug`, as a late error. An error that a stream brings while the cancellation of its send is pending (e.g. its `error`, once a failed error handler or a failed pipe has recorded the cancellation of the pending sends, before it is carried out, see [Errors](#errors) and [Pipes into the response](#pipes-into-the-response)) decides nothing: the send rejects with the reason of the cancellation, and that error is logged once at `debug`, as a late error. One that has ended already (`readableEnded`, or the `endEmitted` of its state) is an empty body, and one destroyed before (`destroyed`) rejects at once with the error that it has been destroyed with (its `errored`, e.g. of `readable-stream` 4; otherwise `ERR_STREAM_PREMATURE_CLOSE`), without being read nor destroyed again (and closes a response whose headers are committed), and that error is not logged: only its guard listens. A `Uint8Array` is sent as its bytes. A view (given to `send()`, or a chunk of a `Readable`) is read once, right before it is sized and written, by the getters of the engine (`buffer`, `byteOffset` and `byteLength` of the typed arrays and of `DataView`, and `detached` of its `ArrayBuffer`), never by a getter of its own (e.g. of a subclass, which may lie, or detach its buffer as it is read): binden writes a `Buffer` of its own over those bytes, so that Node.js reads nothing of the view, nor the `byteLength` of its `ArrayBuffer`. A view of an `ArrayBuffer` that has been detached (e.g. by its `transfer()`), whose bytes are gone, rejects with a `TypeError` of binden ("The data is a view of a detached ArrayBuffer") before anything is changed, whatever view it is (also a typed array that is not a `Uint8Array`, e.g. a `Float64Array`, or a `DataView`, which are refused otherwise as any other object, see below), and so does a `DataView` out of the bounds of a resizable `ArrayBuffer` that has shrunk ("The data is a view out of the bounds of its ArrayBuffer"), whose bounds the engine does not tell; a `Uint8Array` (or a `Buffer`) out of its bounds has no bytes, as the engine tells (an empty body), and any other typed array is refused as any other object, also out of its bounds. A chunk of a `Readable` that is a view of a detached `ArrayBuffer` (a typed array or a `DataView`), or a `DataView` out of its bounds (e.g. of an object-mode `Readable.from()`), is refused with the same `TypeError`: it is never written, since Node.js would throw once it writes it, out of any call, and crash the process; it fails the send at once with that error, decided as the other errors of binden (a first chunk before the headers are committed, so that the error can still be replied; a later one cuts the response), and the stream is destroyed with it. The `Buffer` that binden writes shares the bytes of the view: they must stay as they are (not detached, shrunk or resized) until the send resolves, and those of a chunk of a stream until its write has completed, since Node.js may write them later (e.g. once the response before it on its connection has been sent). Bytes that are gone right after binden has written them, or fewer than were counted (e.g. a `toString()` of a cookie, which runs while the head is committed, or a `filter` of @binden/compression has detached the `ArrayBuffer`, or shrunk a resizable one under the view), cut the response: it is destroyed by the framework before anything that it has queued is flushed (a response that waits for the one before it on its connection is cut once it gets its socket), so that the connection is closed, and the send rejects with the `TypeError` of a view of a detached `ArrayBuffer`. Only a write that the library that has replaced the response inside it reports as discarded is not checked. It reports through the internal `kDiscardWrite`: the `406` and the `500` of @binden/compression report each write and `end()` of the application that they discard, and call it back. Its bytes are no part of the response, whose head frames its own empty body. The send then settles as the response does (it resolves once the `406` is sent), and the connection is reused. Every other write is checked, also when it has ended the response: bytes that a writer gives to binden by `write()` or by `end()`, bytes that a compressor has accepted, and bytes that a writer drops without reporting it. A chunk of no bytes is never cut. A change once that write has returned is not seen: the framing already written (the `Content-Length`, or the size of the chunk) no longer matches the bytes, and a keep-alive connection desyncs (the client reads what follows as the body), as with a direct write of plain `node:http`, which also crashes the process for a response that waits for the one before it on its connection (it writes the view later, out of any call), where binden does not. A chunk of a `Readable` that is a view but not a `Uint8Array` (e.g. a `DataView` or a `Uint16Array`) is written as its bytes, as a chunk of a stream-like source is. Any other object (e.g. a plain object, a `URLSearchParams`, or an object with only `on()` and `pipe()`) rejects with a `TypeError` before anything is sent, without calling it. A `Readable` is no longer read (and is destroyed) once another writer has ended the response, e.g. a compression that has replaced it with `406 Not Acceptable` when its first chunk committed the headers. An ended response is left as it is, whether another writer or `send()` itself (with the whole body) has ended it: a later failure of the source (e.g. of a `_destroy()` that fails right after its end) neither rejects the send nor cuts the reply, which is sent whole, and the responses after it on the connection too; the send settles as the response does (it resolves once the response is flushed, and rejects when it is closed before), and the error of the source is logged once at `debug`. Before, while nothing is sent and the headers are not committed (e.g. by `flushHeaders()` or `writeHead()`), the error of a failed send can still be replied; otherwise the response is cut (also one that waits for the response before it on its connection: its head is never sent), so that the client knows that it is truncated. The error of a source once the response has ended is a late error (see above), logged once: when code of the source throws that same object for a listener of the guard that its send then installs (e.g. a listener of its `newListener`), it is not logged as a refusal of the guard as well. The request of the response itself (an echo, `send(context.request)`: the request itself, not a `Proxy` of it) is never destroyed, whether its send is released (to a response without a body, for an informational status, or once it is ignored), cancelled, or fails (e.g. a body longer than its `Content-Length`): its destroy would close the connection, and with it the reply (e.g. of the error or of the deadline that follows) and the responses pipelined before it. It is only no longer read: Node.js reads the rest of its body once the reply has finished, and a reply of the framework closes the connection when that body has not come in full (see [Errors](#errors)). Once its send has settled, it gets a guard as well (it is never destroyed by it), so that a later error of it (e.g. its abort, once the client goes away) is logged at `debug`. Its failure rejects the send before the headers are committed, so that the error can still be replied; once they are, the response, and with it the connection, is closed. An unknown `encoding` of a string rejects with a `TypeError` before anything is sent. The digests that the application sets ([RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html)) stay true for what is sent: `Content-Digest` (of the content of the message) is removed when the response sends none of the body (to `HEAD`, with a 204, 205 or 304 status, also the `304` of the dynamic `ETag` and of `.sendFile()`) or a part of it (a `206` of `.sendFile()`), and `Repr-Digest` and the legacy `Digest` (of the representation) are kept then, but removed with `Content-Digest` when another response replaces the representation (the `412` of a failed precondition, a `416`, or a `406` of a compression). Such an empty replacement (of the dynamic `ETag` and of `.sendFile()`) has no `Trailer` either, which Node.js refuses without a chunked body: a `Trailer` set for the body that it replaces does not turn it into a `500`. + +```typescript +await response.send( + "Could be `number` | `string` | `Buffer` | `Uint8Array` | `Readable` | `IStreamLike` | `bigint` | `undefined`", +); +``` + +- `.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, as does a `stringify` that is neither `undefined` nor a function (e.g. `5`, "`stringify` is not a function"), so that the error can still be replied (what `JSON.stringify()` throws, e.g. for a nested `bigint`, a cycle or the error of a `toJSON()`, with its stack formatted) + +```typescript +const json = { k: "v", k1: 1, m: "message", f: false }; +await response.json(json); +``` + +or using a custom `stringify` function, which must return a string + +```typescript +const json = { currency: "💶", value: 120 }; +const { default: 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, stringify); +``` + +- `.text()` - send text as `text/plain; charset=utf-8`. As `.html()`, `.json()` and `.form()`, it sets its `Content-Type` only once `.send()` has accepted the data and the status, so that a refused call (e.g. `text({})`, `html(null)` or `status(101).json({})`) changes nothing of the response, and, as `.send()`, it is ignored once the response has ended (or once its work may no longer mutate it), without reading or checking anything of its arguments (nor running the `stringify` of `.json()`) + +```typescript +const text = "Hello World!"; +await response.text(text); +``` + +- `.html()` - send text as `text/html; charset=utf-8` + +```typescript +const html = ""; +await response.html(html); +``` + +- `.form()` - send `URLSearchParams`, as Node.js serializes them (`URLSearchParams.prototype.toString()`; an override of `toString()` in a subclass is not called). Anything else (e.g. `null`, a string, a proxy, or an object that only looks like them) rejects with a `TypeError` of binden ("The parameters are not URLSearchParams") before the response is started; + +```typescript +const form = new URLSearchParams([ + ["a", "1"], + ["b", "a"], + ["b", "c"], +]); +await response.form(form); +``` + +- `.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 location that is not a valid URL (e.g. `http://[bad`, or a port out of range) is refused as well, with a `TypeError` of binden ("The location is not a valid URL"), not the `ERR_INVALID_URL` of `new URL()`. A refused location rejects with a `TypeError` before the response is started. Options that are neither an object nor `undefined` (e.g. `null`) reject with a `TypeError` of binden ("`options` is not an object"), as does an `allow_external` that is not a boolean. + +```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(); +``` + +- `writeInformation()` needs Node.js 24.18.0 or later: on an older version it throws a `TypeError` of binden ("writeInformation() is not supported by this version of Node.js (24.18.0 or later)"), unless the call is ignored (by a work that may no longer change the response, or for an HTTP/1.0 client). Its signature without headers, `writeInformation(code, callback)`, which `@types/node` declares and Node.js 26.11.1 refuses itself, sends no headers. `writeContinue()` and `writeProcessing()` take any callback, as Node.js does. +- Informational responses (`writeEarlyHints()`, `writeContinue()`, `writeProcessing()` and `writeInformation()`) are never sent to an HTTP/1.0 client, which does not know them (RFC 9110, section 15.2): the call is ignored, and its callback is called once, asynchronously. They read the HTTP version of the request of the response as they are called: what reading it throws (e.g. of a proxy of the request whose getters throw) is thrown as it is, formatted. +- Informational responses (`writeEarlyHints()`, `writeContinue()`, `writeProcessing()` and `writeInformation()`) of a pipelined request are queued until the response before it is complete, and are sent before the response itself, whatever writes its body. Node.js 24 and 26 send the head of a queued response before them when the first chunk of its body is bytes (a `Buffer`, e.g. of `.send()`, `.sendFile()` or a stream) or a string in an encoding other than exactly `utf8` or `latin1` (e.g. `utf-8`, `hex` or `base64`), so that the client reads the `103 Early Hints` as the body: a defect of Node.js (not reported upstream yet). The response works around it by committing its head with an empty `latin1` write, framed as Node.js frames that chunk (e.g. the `Content-Length` of the only chunk of `end()`), then writes the chunk. That chunk is checked first, as Node.js checks it: an unknown encoding throws `ERR_UNKNOWN_ENCODING` before anything is sent. +- Once the head of the response is committed, `writeContinue()`, `writeProcessing()`, `writeEarlyHints()` and `writeInformation()` throw `ERR_HTTP_HEADERS_SENT` ("Cannot write headers after they are sent to the client"), formatted, before they read their arguments, on every version of Node.js: Node.js before 24.18.0 would write the informational head into the body, which the client reads as part of it, or past its `Content-Length` as the next response of the connection (Node.js 26 throws). A call that is ignored (a sealed response, an HTTP/1.0 client) stays ignored, its callback called asynchronously. +- A string that `write()` or `end()` writes in `hex`, `base64` or `base64url` (in any case) is written as the bytes that `Buffer.from()` decodes, and framed by them (e.g. the `Content-Length` of the only chunk of `end()`, or the size of a chunk): Node.js only estimates the length of such a string, so that for an invalid one (e.g. `"zz"` in `hex`, which decodes to nothing) it would declare more bytes than it writes, and the client would read the next response of the connection as the rest of the body; Node.js 24 also aborts the process when it writes an odd number of hex digits to a socket (Node.js 26 drops the last one, as `Buffer.from()` does). An explicit `Content-Length` is never changed: with `strictContentLength`, it is checked against these bytes. + +- 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, also an empty one (`.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. The preconditions of the request are then evaluated as by `.sendFile()` (see below), with that `ETag` and the `Last-Modified` that the response may have: `412 Precondition Failed` (empty) when `If-Match` (or else `If-Unmodified-Since`) fails, and `304 Not Modified` (without a body, with the same `ETag`, `Vary`, `Cache-Control` and `Content-Type`, and the `Content-Length` of the `200`, as RFC 9110 allows) when `If-None-Match` matches by the weak comparison (or else `If-Modified-Since` is not older than the `Last-Modified`). They apply to the representation that is sent: a body that [`@binden/compression`](https://github.com/binden-js/binden/tree/main/packages/compression) compresses has a weak `ETag`, which a strong `If-Match` never matches, and a response that it replaces with `406 Not Acceptable` stays one, whatever the preconditions. 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, in this order, once the representation that is sent is chosen: the file as it is, or the one of a compression ([`@binden/compression`](https://github.com/binden-js/binden/tree/main/packages/compression) chooses it before the preconditions, without sending anything yet). A compressed file has the weak form of the `ETag` of the response (`W/"…"`) and no ranges, and a file that the compression replaces with `406 Not Acceptable` gets it, whatever the preconditions and ranges are + - [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` (empty, without a `Content-Encoding`) when it does not match (by the strong comparison; `*` matches any representation, and a weak `ETag` never matches, e.g. the one of a compressed 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` (with the headers of the file and its `Content-Length`), `412 Precondition Failed` for other methods + + - [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - for `GET` of the file as it is only (a compressed one is sent whole, also with `If-Range`): `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 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. + + Once its send would be ignored (the response has ended, or the work that has called it can no longer change the response, e.g. a stack whose deadline has passed once its headers were sent, see [Lifecycle](#lifecycle)), it resolves without sending anything: its file is not opened (called so, it resolves at once, without checking its options nor resolving its path), or is closed at once, and so is a stream of the file that its send settles without reading. Before the headers are committed, a deadline, a failure or a time out of the error handler, or a failed pipe of the stack or of the error handler cancels it instead, whatever work has called it: it rejects with the reason of the signal (see [Lifecycle](#lifecycle)), with the failure of the handler (see [Errors](#errors)), or with the error of the pipe (see [Pipes into the response](#pipes-into-the-response)). Its headers and its status are then those from before it, or from before the earliest send cancelled with it (e.g. a second `sendFile()` started once the first had set the headers of its file), also when it has settled otherwise between that failure and its cancellation (e.g. once its file has failed): the reply that replaces it inherits none of them, no framing of a body (`Content-Length`, `Transfer-Encoding`, `Content-Range`, also one set before it, e.g. by the application), nor the representation that a compression has chosen for the file. + + The file is the body of the status set before (`200` by default): a redirect or an error (e.g. `response.status(404).sendFile("404.html", { root })`) is sent whole with its status, without evaluating the preconditions ([RFC 9110, section 13.2.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-13.2.1): they apply to a `2xx` or `412` only, also for other methods, e.g. `201` to `PUT`, but never to `CONNECT`, `OPTIONS` and `TRACE`, which neither select nor modify a representation), and the ranges apply to a `200` only. + + `sendFile()` with a `1xx` status rejects with the `TypeError` of `send()` ("Status code 103 is informational (use writeEarlyHints())") before it reads its options, resolves its path or sets anything. A `sendFile()` that fails once it has set the headers of the file, before the head is committed (e.g. a head that Node.js refuses, or a status that the application has made informational while the file was opened), restores the status and the headers from before it, without the framing of a body (also a `Content-Length` set before it) nor the representation chosen for it, as a cancellation does: the reply that follows (e.g. of the error) has none of the headers of the file (`ETag`, `Last-Modified`, `Content-Type`, `Cache-Control`, `Accept-Ranges`, `X-Content-Type-Options`, `Content-Length`). After any unwind that removes a `Content-Length` (a failed or cancelled `sendFile()`, the file's own `Content-Length` included, or a failed send of a stream), a reply that follows by `write()` and `end()`, which sets no length of its own, is chunked (also a single `end(chunk)`). When the application had set a `Transfer-Encoding` before the send, that reply is framed by the close of the connection: its head still says `keep-alive`, the socket is closed after it, and a pipelined request that follows is not answered (nothing is misread). `.send()` and the default error reply set their own `Content-Length`, so that a pipelined request that follows is answered. A failure before them (e.g. a path that does not exist, or a file that cannot be opened) leaves the response as it is. + + 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 (a socket, a device or a FIFO is not even opened), 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 path and the options are read inside the boundary of its errors (see [Lifecycle](#lifecycle)). The other options (an invalid one rejects with a `TypeError` or a `RangeError` before the file is opened, e.g. a `cache_control` or a `validator` that is not a string, which is never converted; an asynchronous failure, e.g. the `ENOENT` of a missing file, is handed over as it is): + +- `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 + +The constructor of a header takes an object as its init (anything else throws a `TypeError`, "`init` is not an object: …") and reads each of its properties once, inside the boundary of its errors (see [Lifecycle](#lifecycle)), and `Accept` and `ContentType` read their `parameters`, an object too ("`parameters` is not an object: …"), once (with `Object.entries()`): it checks those values and keeps exactly them, so that a getter or a proxy that gives another value on a later read cannot make it keep a value that it has not checked. A value that is not a string where the init declares one (e.g. of JavaScript: an object whose `toString()` gives another string on a later call) is converted once with `String()`, and exactly that string is checked and kept: the name, the value, `domain` and `path` of a `Cookie`, the media range and the parameter values of `Accept`, the media type and the parameter values of `ContentType`, the charset of `AcceptCharset`, the language range of `AcceptLanguage` and the credentials of `Authorization`. A `Symbol`, or a value that cannot be converted (e.g. an object without a prototype, or one whose `toString()` and `valueOf()` give no primitive), throws a `TypeError`, formatted; what a conversion of the value's own throws (e.g. its `toString()`) is thrown as it is (formatted when it is an error). No other value is converted: an unknown coding (of `AcceptEncoding` and `ContentEncoding`) or scheme (of `Authorization`), a weight (`q_value`) of `Accept`, `AcceptCharset`, `AcceptLanguage` and `AcceptEncoding` that is not a number (or `null`: "`q_value` is not a number"), a `max_age` that is not a safe integer and a `same_site` that is not `Lax`, `None` or `Strict` throw a `TypeError` that describes them without converting them (see [Lifecycle](#lifecycle)). The `date` of an `IfModifiedSince` is a `Date`, read by its time (which the engine keeps, never by its methods), or a number or a string, read by `new Date()`; anything else throws a `TypeError` ("The date is not a Date: …"). `new Cookie()` reads its `expires` in the same way, and keeps a copy of it: a proxy of a `Date`, which has no time of its own, throws a `TypeError` ("The Expires is not a Date"), and any other value that is not a `Date` gives no `Expires` (`null`). The parsers of a header (`fromString()`, `Forwarded.parse()` and the header given to `negotiate()`) take `undefined` or a string, and those of `Accept`, `AcceptCharset`, `AcceptEncoding`, `AcceptLanguage` and `Forwarded` an array of strings too (the lines of the field, read once, by their indexes); anything else, also an element that is not a string, throws a `TypeError` ("The header is not a string: …", "The header is not an array: …" or "A field line is not a string: …"). The offers of `negotiate()` are an array of strings, read once in the same way, and an offer that is not a string is refused as an invalid one (e.g. "Invalid media type: (an object)"). A parser checks an element of the header before it constructs it: an invalid element is skipped (an invalid `Content-Type`, `Content-Range` or `Authorization` gives `null` or `[]`) without creating an error. + +- [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) + +```typescript +import { Authorization } from "binden"; + +const credentials = Authorization.fromString(request.headers.authorization); +// or using BindenRequest +const { authorization } = request; +``` + +- [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"; + +const encodings = ContentEncoding.fromString( + request.headers["content-encoding"], +); +// or using BindenRequest +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, 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 + +```typescript +import { ContentRange } from "binden"; + +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) - 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"; + +const type = ContentType.fromString(request.headers["content-type"]); +// or using BindenRequest +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. 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"; + +const parsed = 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); +``` + +- [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) - `Forwarded.parse()` returns the elements of a field value (or of its field lines, e.g. `request.headersDistinct.forwarded`) in their positions (`null` for an invalid one: a parameter that is not `name=value`, that is given twice, or with control characters; a field line is read from its end, so that an unterminated quoted string invalidates what is before it only), 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"; + +const elements = Forwarded.parse(request.headers.forwarded); +const proxies = Forwarded.fromString(request.headers.forwarded); +// or using BindenRequest +const { forwarded } = request; +``` + +- [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) + +```typescript +import { IfModifiedSince } from "binden"; + +const ims = IfModifiedSince.fromString(request.headers["if-modified-since"]); +// or using BindenRequest +const { if_modified_since } = request; +``` + +- [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) + +```typescript +import { Range } from "binden"; + +const ranges = Range.fromString(request.headers.range); +// or using BindenRequest +const { range } = request; +``` diff --git a/packages/binden/index.ts b/packages/binden/index.ts new file mode 100644 index 00000000..37507bcf --- /dev/null +++ b/packages/binden/index.ts @@ -0,0 +1,87 @@ +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"; +export * from "./src/headers/content-type.js"; +export * from "./src/headers/cookie.js"; +export * from "./src/headers/forwarded.js"; +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, + type IExchangeReport, + formatStack, +} from "./src/exchange.js"; +export { described, formatting } from "./src/formatting.js"; +export * from "./src/binden.js"; +export * from "./src/middleware.js"; +export * from "./src/request.js"; +export * from "./src/response.js"; +export { + type IOwnership, + type IPipeFailure, + type IRepresentation, + type IRepresentationNegotiator, + type IResponseOperation, + bodyHeaders, + kDiscardWrite, + kListen, + kUnlisten, + kNegotiator, + kOperation, + kRepresentation, + kValidateHead, + releasedFailure, +} from "./src/seal.js"; +export { + type IFramingState, + framingFields, + framingLength, + kRestoreFraming, + kSaveFraming, +} from "./src/framing.js"; +export type { IRedirectOptions, IRedirectStatus } from "./src/redirect.js"; +export type { ISendFileOptions } from "./src/send-file.js"; +export type { + IStreamLike, + IStreamLikeListener, + IStreamLikeOff, + IStreamLikeRemovable, + IStreamLikeSource, +} from "./src/stream-like.js"; +export * from "./src/router.js"; +export { + isClosing, + shutdown, + type IServer, + type IShutdownOptions, +} from "./src/shutdown.js"; +export type { + IRouteParamName, + IRouteParams, + IRouteSegments, + IRoutingOptions, +} from "./src/route.js"; +export { + Stack, + type IStack, + type IStackItem, + type IStackPath, + type IUntypedItem, +} from "./src/stack.js"; +export { + type IBindenSerializers, + type ILocation, + type ILogLevel, + childLogger, + createSerializers, + logTo, + record, + serializers, +} from "./src/logger.js"; diff --git a/packages/binden/package.json b/packages/binden/package.json new file mode 100644 index 00000000..1db47c79 --- /dev/null +++ b/packages/binden/package.json @@ -0,0 +1,77 @@ +{ + "name": "binden", + "version": "0.0.0-development", + "description": "A simple HTTP server framework", + "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" + }, + "sideEffects": false, + "files": [ + "dist", + "!dist/test", + "index.ts", + "src", + "MIGRATION-4.md", + "PRODUCTION.md" + ], + "engines": { + "node": ">=24.14.1" + }, + "scripts": { + "build": "tsc", + "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": { + "type": "git", + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/binden" + }, + "keywords": [ + "HTTP", + "REST", + "web", + "framework", + "server", + "router", + "routing", + "middleware", + "api", + "typescript" + ], + "author": "Sergey Bakulin ", + "license": "MIT", + "bugs": { + "url": "https://github.com/binden-js/binden/issues" + }, + "homepage": "https://github.com/binden-js/binden/tree/main/packages/binden#readme", + "dependencies": { + "@binden/logger": "^0.0.0-development" + }, + "devDependencies": { + "@types/sinon": "^22.0.0", + "fast-json-stringify": "^7.1.0", + "node-fetch": "^3.3.2", + "readable-stream-v2": "npm:readable-stream@^2.3.8", + "readable-stream-v3": "npm:readable-stream@^3.6.2", + "readable-stream-v4": "npm:readable-stream@^4.8.0", + "sinon": "^22.1.0", + "through2-v2": "npm:through2@^2.0.5" + }, + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } +} diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts new file mode 100644 index 00000000..1fa99047 --- /dev/null +++ b/packages/binden/src/binden.ts @@ -0,0 +1,1740 @@ +import { BindenAbortError, Exchange, formatStack } from "./exchange.js"; +import { + BindenRequest, + type IRequestOptions, + type IRequestOptionsInput, + validateRequestOptions, +} from "./request.js"; +import { + BindenResponse, + type IResponseOptions, + ct_json, + ct_text, + validateResponseOptions, +} from "./response.js"; +import { type ILayer, Stack, layersOf, markRoot } from "./stack.js"; +import { + type ILocation, + type ILogLevel, + childLogger, + defaultLogger, + isLogger, + logTo, + record, + safe, + snapshot, +} from "./logger.js"; +import { + type IMatchOptions, + type IRoutingOptions, + matchPrefix, + matchRoute, + split, +} from "./route.js"; +import { type IMiddleware, Middleware } from "./middleware.js"; +import { + bodyHeaders, + cookieGuards, + kCancel, + kCancelling, + kFramework, + kListen, + kOperation, + kOwnership, + kSeal, + owners, + releasedFailure, +} from "./seal.js"; +import { formatting, objectOf } from "./formatting.js"; +import { isClosing, register, track } from "./shutdown.js"; +import { pathOf, property } from "./property.js"; +import { BindenError } from "./error.js"; +import { Context } from "./context.js"; +import type { ILogger } from "@binden/logger"; +import { Router } from "./router.js"; +import http from "node:http"; +import https from "node:https"; +import { isContext } from "./brands.js"; +import { validateDelay } from "./delay.js"; + +/** + * Creates the state of a request (see the `state` option of `Binden`), + * synchronously: a promise (or any thenable) is not a state + */ +export type IStateFactory = () => State & { + /** A state is not a promise: the factory is synchronous */ + readonly then?: never; +}; + +/** + * 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`) */ +export type IErrorHandler> = ( + context: Context, + 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`: 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`) + */ + auto_head?: boolean | undefined; + /** Replaces the default reply to an error, unless it does not reply itself */ + 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 + * `500 Internal Server Error` instead (or the connection is closed, once + * its reply has committed its headers) + */ + 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 committed its headers). 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 | 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; + /** + * Whether every request is logged once its response is closed (`false`): + * `true` or the options of the access log + */ + access_log?: IAccessLogOptions | boolean | undefined; + /** + * 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`) + */ + etag?: boolean | 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 + ? { + /** Creates the state of every request (an empty object by default) */ + state?: IStateFactory | undefined; + } + : { + /** Creates the state of every request: it has required fields */ + state: IStateFactory; + }); + +/** Thrown through the stack once the request has been aborted: the stack stops */ +const ABORTED = formatStack(new Error("The request has been aborted")); + +/** The route of the access log for the replies of the framework (e.g. `404`) */ +const UNMATCHED = ""; + +/** + * The encodings of the strings whose length Node.js only estimates: the + * response writes such a string as its bytes (`Buffer.from()`), which the + * access log counts + */ +const estimated: ReadonlySet = new Set(["base64", "base64url", "hex"]); + +/** The outcome of an `error_handler` that has not settled in time */ +const EXPIRED = Symbol("expired"); + +/** + * The outcome of an `error_handler` that was still running once the deadline + * passed: the reply of the deadline has taken over (see `#deadline()`) + */ +const SUPERSEDED = Symbol("superseded"); + +/** The options of an application, read once and checked (see `Binden`) */ +interface ISettings { + readonly error_handler: IErrorHandler | null; + readonly error_handler_timeout: number; + readonly auto_head: boolean; + readonly logger: ILogger | undefined; + readonly log_error_details: boolean; + readonly access: IAccessLog | null; + readonly case_sensitive: boolean; + readonly strict: boolean; + readonly state: (() => object) | null; + readonly timeout: number; + readonly requests: IRequestOptions; + readonly responses: IResponseOptions; +} + +/** The context of a request, which a middleware may replace */ +interface IRun { + context: Context; +} + +/** + * The works of a request that own its response (see `owners`): its + * middleware stack, then the runs of its error handler + */ +interface IWorks { + /** The token of the middleware stack */ + readonly stack: object; + /** The tokens of the runs of the error handler */ + readonly handlers: Set; + /** What a failure of a pipe of a work does (see `IOwnership.fail()`), by its token */ + readonly failures: Map void>; + /** Whether the error handler has started: the outcome of the stack is decided */ + handled: boolean; +} + +/** 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; + /** The template of the layer that the access log names (`""` for none) */ + readonly route: 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: IErrorHandler | null; + readonly #error_handler_timeout: number; + readonly #timeout: number; + readonly #logger: ILogger; + readonly #details: boolean; + readonly #access: IAccessLog | null; + readonly #routing: Required; + readonly #state: (() => object) | null; + readonly #Request: typeof BindenRequest; + readonly #Response: typeof BindenResponse; + /** Whether a value is a request of the class of the application */ + readonly #isRequest: (value: unknown) => value is BindenRequest; + /** Whether a value is a response of the class of the application */ + readonly #isResponse: (value: unknown) => value is BindenResponse; + readonly #request_id: IRequestOptions["request_id"]; + /** The works of the requests (see `IWorks`), by their exchange */ + readonly #works = new WeakMap(); + + /** + * 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`), and so do options that are not an + * object. Each option is read once: what reading them throws (e.g. a + * revoked proxy) is formatted (see `formatting()`). + */ + public constructor( + ...[options = {} as IBindenOptions]: Record< + never, + never + > extends State + ? [options?: IBindenOptions] + : [options: IBindenOptions] + ) { + super(); + const settings = formatting(() => Binden.#settings(options)); + const { requests, responses } = settings; + markRoot(this); + this.#access = settings.access; + this.#error_handler = settings.error_handler; + this.#error_handler_timeout = settings.error_handler_timeout; + this.#timeout = settings.timeout; + this.#auto_head = settings.auto_head; + // Checked by its methods (see `#settings()`), and wrapped unless it is a + // logger of binden, told by its brand (see `safe()`) + this.#logger = formatting(() => safe(settings.logger ?? defaultLogger())); + this.#details = settings.log_error_details; + this.#routing = { + case_sensitive: settings.case_sensitive, + strict: settings.strict, + encoded_slashes: requests.encoded_slashes, + }; + this.#state = settings.state; + this.#request_id = requests.request_id; + // The requests and the responses of this application, with its options, + // told by their private brands (see `callback()`) + // eslint-disable-next-line init-declarations -- set by the static block of the class + let isRequest!: (value: unknown) => value is BindenRequest; + // eslint-disable-next-line init-declarations -- set by the static block of the class + let isResponse!: (value: unknown) => value is BindenResponse; + this.#Request = class extends BindenRequest { + readonly #application: undefined; + + static { + isRequest = (value: unknown): value is BindenRequest => + typeof value === "object" && value !== null && #application in value; + } + + // 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 { + readonly #application: undefined; + + static { + isResponse = (value: unknown): value is BindenResponse => + typeof value === "object" && value !== null && #application in value; + } + + // eslint-disable-next-line class-methods-use-this -- the options of the app + protected override get options(): IResponseOptions { + return responses; + } + }; + this.#isRequest = isRequest; + this.#isResponse = isResponse; + } + + /** + * Creates an HTTP server that handles its requests with the application + * (registered for `shutdown()`): options that are not an object throw a + * `TypeError`, and what reading them or creating the server throws (e.g. + * an argument error of Node.js) is formatted (see `formatting()`) + */ + public createServer( + options?: Omit, + ): http.Server { + return formatting(() => { + const server = http.createServer({ + ...objectOf(options, "options", true), + ...this.serverOptions(), + }); + server.on("request", this.callback(server)); + return server; + }); + } + + /** + * Creates an HTTPS server that handles its requests with the application + * (registered for `shutdown()`): options that are not an object throw a + * `TypeError`, and what reading them or creating the server throws (e.g. + * an argument error of Node.js) is formatted (see `formatting()`) + */ + public createSecureServer( + options?: Omit, + ): https.Server { + return formatting(() => { + const server = https.createServer({ + ...objectOf(options, "options", true), + ...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(): { + /** 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 }; + } + + /** + * 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. + * What registering the server throws (e.g. for a value that is not a + * server) is formatted (see `formatting()`), and so is what the listener + * throws. + */ + public callback( + server: http.Server | https.Server, + ): (request: http.IncomingMessage, response: http.ServerResponse) => void { + formatting(() => { + register(server, (message, error) => { + record(this.#logger, "error", message, { + failure: snapshot(error, { + location: "shutdown", + details: this.#details, + }), + }); + }); + }); + return (request, response): void => { + try { + // Instances of the classes of the application, told by their brands: + // never a proxy of one (whose traps would run later, outside of any + // boundary), nor an object made with `Object.create()` of their + // prototypes + if (!this.#isRequest(request) || !this.#isResponse(response)) { + const message = + "The request and the response are not those of the application (`serverOptions()`)"; + record(this.#logger, "error", message); + request.socket.destroy(); + return; + } + this.#requestListener( + server, + request, + response as BindenResponse, + ); + } catch (error: unknown) { + throw formatStack(error as Error); + } + }; + } + + #requestListener( + server: http.Server | https.Server, + request: BindenRequest, + response: BindenResponse, + ): void { + const { state, failure } = this.#createState(); + const context = new Context({ + request, + response, + state, + logger: this.#logger, + report: (message, error): void => { + this.#logFailure(context, "error", message, error, "cleanup"); + }, + }); + const exchange = Exchange.for(request, response); + track(server, exchange, response); + // Once the exchange is sealed (e.g. after the deadline), only the reply + // of the framework mutates the response: the sends, writes, headers, + // status and cookies of the abandoned work do nothing + response[kSeal]((owner) => exchange.allows(owner)); + const works: IWorks = { + stack: exchange.stack(), + handlers: new Set(), + failures: new Map(), + handled: false, + }; + this.#works.set(exchange, works); + exchange.onLateFailure( + Binden.#lateReport( + this.#logger, + context.id, + this.#details, + "A pipe of a work has failed once its request had completed", + ), + ); + response[kOwnership]({ + fail: (owner, error): boolean => { + const fail = works.failures.get(owner); + if (typeof fail === "undefined") { + // Not a work of this request + return false; + } else if (exchange.allows(owner)) { + fail(error); + } else { + // E.g. a listener of `end` that it has added to its source + const message = "A pipe of an abandoned work has failed"; + this.#logFailure(context, "debug", message, error, "send"); + } + return true; + }, + guards: (owner): boolean => works.handlers.has(owner), + // Not once the connection terminates (e.g. the abort listeners of a + // shutdown destroy the streams of the error handler) + replier(): object | null { + return exchange.terminating ? null : exchange.current_reply; + }, + native: (owner): boolean => owner === works.stack, + debug: (message, error): void => { + this.#logFailure(context, "debug", message, error, "send"); + }, + warn: (message, reason): void => { + logTo(context, "warn", message, { reason }); + }, + note: (message, reason): void => { + logTo(context, "debug", message, { reason }); + }, + released: Binden.#lateReport( + this.#logger, + context.id, + this.#details, + releasedFailure, + ), + }); + 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); + } + 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) + ? this.#closing(context) + : this.#run(context, exchange, works, failure); + handled.catch((error: unknown) => { + // The process must not crash + this.#unexpected(context, response, error); + }); + } + + /** + * How a late failure of a request is logged with the message, at `debug`, + * by a function of its own scope, which holds neither the request nor its + * context: once the request has completed, what is left of its works (see + * `Exchange.onLateFailure()`, e.g. a listener of `end` that one has left on + * a shared source), and a late error of a stream that it has released, + * the request included once a send of it no longer listens to it (see + * `IOwnership.released`). Given a message of its own, it logs the failure + * with that one (e.g. a removal of a listener of the guard of a stream that + * throws, which is not a late error) + */ + static #lateReport( + logger: ILogger, + id: string, + details: boolean, + message: string, + ): (error: unknown, logged?: string) => void { + return (error, logged = message): void => { + // Through the private paths of the logger (see `record()`) + record(childLogger(logger, { trace_id: id }), "debug", logged, { + failure: snapshot(error, { location: "send", request_id: id, details }), + }); + }; + } + + /** + * 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) cancels a send + * whose headers are not committed and replies `503`, or closes the + * connection of a response that has not finished. + */ + async #run( + first: Context, + exchange: Exchange, + works: IWorks, + initial: { error: unknown } | null, + ): Promise { + const run: IRun = { context: first }; + let failure = initial; + // A pipe of the stack that fails (see `IOwnership.fail()`): the stack may + // no longer mutate the response, and stops; its later result is ignored + const outcome: { piped: { error: unknown } | null } = { piped: null }; + let running = true; + const { promise: halted, resolve: halt } = Promise.withResolvers(); + works.failures.set(works.stack, (error) => { + if (works.handled) { + // Its outcome is decided already: only its own work is abandoned + exchange.revoke({ owner: works.stack, error }); + const message = "A pipe of the stack has failed once it had failed"; + this.#logFailure(run.context, "debug", message, error, "send"); + return; + } + works.handled = true; + exchange.seal({ owner: works.stack, error }); + exchange.halt(); + outcome.piped = { error }; + if (running) { + halt(null); + } else { + const { context } = run; + this.#pipeFailed(context, exchange, error).catch((unexpected) => { + this.#unexpected(context, first.response, unexpected); + }); + } + }); + try { + if (failure === null) { + await Promise.race([ + owners.run(works.stack, () => this.#handle(run, exchange)), + halted, + ]); + } + } catch (error: unknown) { + // Stopped (`ABORTED`): once aborted, or once a pipe has failed + if (error !== ABORTED) { + failure = { error }; + } + } + running = false; + + const { context } = run; + const { signal } = exchange; + const { piped } = outcome; + if (signal.aborted) { + // Stopped, or completed meanwhile: a failure once aborted is a + // consequence (e.g. of the client going away), and a deadline that has + // passed replies (its listener below would come too late) + await this.#aborted(context, exchange); + return; + } + + // From now on, also while the error handler runs, a deadline replies by + // itself (the error handler is then abandoned, see `#bounded()`) + signal.addEventListener( + "abort", + () => { + const { reason } = signal as { reason: BindenAbortError }; + if (reason.reason === "deadline") { + this.#deadline(context, exchange).catch((error: unknown) => { + this.#unexpected(context, first.response, error); + }); + } + }, + { once: true }, + ); + if (piped !== null) { + await this.#pipeFailed(context, exchange, piped.error); + } else if (failure) { + const { error } = failure; + await exchange.reply(() => this.#errorHandler(context, exchange, error)); + } + } + + /** + * A pipe of the stack has failed (see `IOwnership.fail()`), whose stack has + * stopped (or had completed): once it has been told (see `kRevoke`), the + * error handler replies to its error, or the connection is closed once the + * headers have been sent, unless the response has ended while it waits for + * the response before it on its connection (pipelined): its reply is + * complete, and is sent once that one has been, as at the deadline (see + * `#deadline()`). A deadline that passes meanwhile replies instead. + * Nothing replaces the reply of a response that has been destroyed (e.g. + * for a pipe that the stack has left to Node.js), but the failure is still + * logged, as once the headers have been sent. + */ + async #pipeFailed( + context: Context, + exchange: Exchange, + error: unknown, + ): Promise { + await exchange.delivered; + const { response } = context; + if (exchange.signal.aborted) { + return; + } else if ( + response.writableEnded && + response.socket === null && + !response.destroyed + ) { + const message = + "A pipe into the response has failed once its reply was complete. It is sent after the responses before it"; + this.#logFailure(context, "error", message, error, "send"); + return; + } else if (response.headersSent || response.destroyed) { + const message = response.headersSent + ? "A pipe into the response has failed once its headers were sent. Destroying the socket" + : "A pipe into the response has failed once it was destroyed"; + this.#logFailure(context, "error", message, error, "send"); + response[kFramework](() => response.destroy()); + return; + } + // The sends of the stack have written nothing: their cancellation, + // recorded at once (see `Exchange.seal()`), is carried out + response[kCancel](error); + await exchange.reply(() => this.#errorHandler(context, exchange, error)); + } + + /** + * An unexpected failure (every failure is handled): the connection of the + * response (the one of the request, which the first context has) is + * closed. The last resort of a request, out of which no logging throws + * (e.g. a context that a middleware has returned, whose `log` throws, see + * `#logFailure()`): no rejection is left unhandled. + */ + #unexpected( + context: Context, + response: BindenResponse, + error: unknown, + ): void { + const message = "The request has failed"; + this.#logFailure(context, "error", message, error, "send"); + response[kFramework](() => response.destroy()); + } + + /** + * 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(); + const { request, response } = run.context; + + if (request.method === "OPTIONS" && request.url === "*") { + // The server as a whole (RFC 9110, section 9.3.7): the stack does not run + Binden.#unmatched(exchange); + response.status(204).setHeader("Allow", this.#methods()); + await response.send(); + return; + } else 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 (response.started) { + return; + } + Binden.#unmatched(exchange); + if (allow.size) { + allow.add("OPTIONS"); + const methods = [...allow].join(", "); + if (request.method === "OPTIONS") { + response.status(204).setHeader("Allow", methods); + await response.send(); + return; + } + throw new BindenError(405, { headers: { Allow: methods } }); + } + 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 formatStack(new Error("A stack is mounted in itself")); + } + chain.push(stack); + + // Split once for every layer of the stack + const parts = split(path); + for (const layer of layersOf(stack)) { + const level = this.#match(layer, path, parts); + 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) { + if (!context.response.started) { + // The route of the access log: the template of the layer that + // runs a middleware, until the reply has started (`*` for a + // `use()` without a path at the root) + const route = exchange.mount_template + level.route; + exchange.route = route === "" ? "*" : route; + } + context = await this.#runMiddleware(middleware, context, exchange); + run.context = context; + if (exchange.has_halted) { + // Also after a synchronous middleware (e.g. whose pipe has been + // refused): the stack stops at once + throw ABORTED; + } else 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, parts: readonly string[]): ILevel | null { + 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, route: match === null ? "" : String(match) } + : null; + } else if (layer.kind === "route") { + const params = matchRoute(layer.pattern, parts, this.#routing); + return params === null + ? null + : { ...none, params, route: layer.pattern.source }; + } + const { pattern } = layer; + const match = matchPrefix(pattern, parts, this.#routing); + return match === null + ? null + : { ...match, template: pattern.source, route: "" }; + } + + /** + * The methods of the routers of the application, in any stack (`HEAD` with + * `GET` and `auto_head`), and `OPTIONS`: the `Allow` of `OPTIONS *` + */ + #methods(): string { + const methods = new Set(); + const seen = new Set(); + const collect = (stack: object): void => { + seen.add(stack); + for (const { items } of layersOf(stack)) { + for (const item of items) { + if (item instanceof Router) { + for (const method of item.methods) { + methods.add(method); + } + } else if (item instanceof Stack && !seen.has(item)) { + collect(item); + } + } + } + }; + collect(this); + if (methods.has("GET") && this.#auto_head) { + methods.add("HEAD"); + } + methods.add("OPTIONS"); + return [...methods].join(", "); + } + + /** + * 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, see `#deadline()`; a client that has gone away (or a + * shutdown) gets nothing + */ + async #aborted(context: Context, exchange: Exchange): Promise { + const { reason } = exchange.signal as { reason: BindenAbortError }; + if (reason.reason === "deadline") { + await this.#deadline(context, exchange); + return; + } + const { response } = context; + if (!response.writableFinished) { + const message = "The request has been aborted. Destroying the socket"; + logTo(context, "debug", message, { reason: reason.reason }); + response[kFramework](() => response.destroy()); + } + } + + /** + * The deadline has passed: the work of the middlewares may no longer + * mutate the response, and a send whose headers are not committed is + * cancelled (its file or stream released). A response that has not started + * then gets a `503` error through the error handler; one that has not + * finished is cut by closing the connection, unless it has ended while it + * waits for the response before it on its connection (pipelined): its + * reply is complete, and is sent once that one has been (destroying it + * would cut that response too, on some versions of Node.js). + */ + async #deadline(context: Context, exchange: Exchange): Promise { + const { response } = context; + // Sealed already (see `Exchange.abort()`): only this reply is allowed + exchange.seal(); + response[kCancel](exchange.signal.reason); + // The abandoned works see their response closed first (see `kRevoke`) + await exchange.delivered; + if (Binden.#closed(context)) { + return; + } else if (!response.started) { + // Formatted (see `formatStack()`): the error handler is given it + const error = formatStack(new BindenError(503, { code: "deadline" })); + await exchange.reply(async () => { + Binden.#closeUnread(context); + await this.#errorHandler(context, exchange, error); + }); + } else if (!response.writableFinished) { + if (response.writableEnded && response.socket === null) { + // Waits for the response before it (see above) + const message = + "The deadline has passed once the response was complete. It is sent after the responses before it"; + logTo(context, "debug", message); + return; + } + const message = "The deadline has passed. Destroying the socket"; + logTo(context, "debug", message); + response[kFramework](() => response.destroy()); + } + } + + /** + * Whether the response has been destroyed once the works that it replaces + * have been told (see `kRevoke`), e.g. a pipe that one of them has left to + * Node.js may still write: nothing replaces their reply + */ + static #closed(context: Context): boolean { + if (context.response.destroyed) { + const message = + "The response has been destroyed: nothing replaces the reply"; + logTo(context, "debug", message); + return true; + } + return false; + } + + /** + * A reply of the framework to a request whose body has not been read in + * full (e.g. abandoned at its deadline) closes the connection: the rest of + * the body would be read as the next request of the connection + */ + static #closeUnread({ request, response }: Context): void { + if (!request.complete) { + response.setHeader("Connection", "close"); + } + } + + /** The access log names the replies of the framework (e.g. `404`) `` */ + static #unmatched(exchange: Exchange): void { + exchange.route = UNMATCHED; + } + + /** A middleware has set `done`: a response that nothing has started is sent empty */ + static async #complete(context: Context): Promise { + if (!context.response.started) { + logTo(context, "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, + }), + }; + } + + /** + * Logs what the framework logs of an error (see `#failure()`) through the + * logger of a context, by its private path (see `record()`): what getting + * it (e.g. a `log` getter of a subclass, or of the context itself) or the + * fields of the record throws (e.g. an `id` getter) drops the record, and + * the request goes on + */ + #logFailure( + context: Context, + level: ILogLevel, + message: string, + error: unknown, + location: ILocation, + status?: number, + ): void { + try { + const { log } = context; + record( + log, + level, + message, + this.#failure(context, error, location, status), + ); + } catch { + // Dropped + } + } + + /** + * 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 { + // The outcome of the stack is decided (see `IWorks`) + const works = this.#works.get(exchange) as IWorks; + works.handled = true; + if (this.#error_handler) { + const message = "Passing error to the provided `error_handler`"; + this.#logFailure(context, "trace", message, error, "middleware"); + + // Its run has its own token (see `Exchange.reply()`): a pipe of it that + // fails (see `IOwnership.fail()`) fails it + const token = owners.getStore() as object; + const { promise: piped, resolve } = Promise.withResolvers<{ + error: unknown; + }>(); + let running = true; + const pipe: { failure: { error: unknown } | null } = { failure: null }; + works.handlers.add(token); + works.failures.set(token, (failure) => { + if (running) { + exchange.seal({ owner: token, error: failure }); + pipe.failure = { error: failure }; + resolve(pipe.failure); + } else { + // Settled: only its own work is abandoned + exchange.revoke({ owner: token, error: failure }); + const late = "A pipe of the provided `error_handler` has failed late"; + this.#logFailure(context, "debug", late, failure, "send"); + } + }); + const outcome = await this.#bounded( + this.#error_handler, + context, + exchange, + error, + piped, + ); + running = false; + + const { response } = context; + if (outcome === SUPERSEDED) { + // The reply of the deadline has started: what this handler does from + // now on (its reply, failure or time out) changes nothing + const abandoned = + "The provided `error_handler` has been abandoned at the deadline"; + this.#logFailure(context, "debug", abandoned, error, "error_handler"); + return; + } else if (outcome !== null) { + // The handler has failed, or has not settled in time: it may no + // longer mutate the response, and the request gets an opaque error + exchange.seal(); + // Its sends that have not committed the headers (e.g. a `sendFile()` + // whose file is still opened, or a stream whose first chunk has not + // come) have written nothing: as at the deadline, they are cancelled + // at once (one that settles before they are, below, rejects with the + // reason, never as an ignored send), and the reply that follows + // replaces them. Its stack is formatted (see `formatStack()`): they + // reject with it + const reason = + outcome === EXPIRED + ? formatStack( + new BindenError(500, { code: "error_handler_timeout" }), + ) + : outcome.error; + response[kCancelling](reason); + if (outcome === EXPIRED) { + const late = "The provided `error_handler` has not settled in time"; + this.#logFailure(context, "error", late, error, "error_handler"); + } else { + const failed = "The provided `error_handler` has failed"; + const { error: failure } = outcome; + this.#logFailure(context, "error", failed, failure, "error_handler"); + } + // It sees its response closed first (see `kRevoke`) + await exchange.delivered; + if (Binden.#closed(context)) { + return; + } + // Each settles with the reason, and releases its file or stream + response[kCancel](reason); + if (response.writableEnded) { + // Its reply is complete: what is left of it (e.g. under + // backpressure) is still sent + return; + } else if (response.started) { + response[kFramework](() => response.destroy()); + } else { + await exchange.reply(async () => { + Binden.#closeUnread(context); + await this.#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 + // `write()` without `end()`) would leave the client waiting, unless + // the response has been closed already (e.g. by the handler) + if (!Binden.#closed(context)) { + const unfinished = + "The provided `error_handler` has not ended its reply. Destroying the socket"; + this.#logFailure(context, "warn", unfinished, error, "error_handler"); + response[kFramework](() => response.destroy()); + } + return; + } + const ignored = "The provided `error_handler` has not replied"; + this.#logFailure(context, "debug", ignored, error, "error_handler"); + } + await this.#defaultErrorHandler(context, error); + } + + /** + * Awaits the `error_handler` for `error_handler_timeout` at most: `null` + * once it has settled, `EXPIRED` once the time has passed, `SUPERSEDED` once + * a deadline has passed while it ran (unless it replies to the deadline + * itself), and its error once it has failed. The result of a handler that + * has expired or been superseded is ignored, and a later failure logged. + */ + async #bounded( + handler: (context: Context, error: unknown) => Promise | void, + context: Context, + exchange: Exchange, + error: unknown, + piped: Promise<{ error: unknown }>, + ): Promise { + // Once the handler is abandoned (or has failed by its pipe), a failure of + // its own (then or later) is only logged + const state: { abandoned: boolean; failure: { error: unknown } | null } = { + abandoned: false, + failure: null, + }; + const late = (failure: unknown): void => { + const message = "The provided `error_handler` has failed late"; + this.#logFailure(context, "debug", message, failure, "error_handler"); + }; + const settled = (async (): Promise => { + await handler(context, error); + })().then( + () => null, + (failure: unknown) => { + state.failure = { error: failure }; + if (state.abandoned) { + late(failure); + } + return state.failure; + }, + ); + const { promise: timeout, resolve } = + Promise.withResolvers(); + const timer = setTimeout(resolve, this.#error_handler_timeout, EXPIRED); + timer.unref(); + const outcomes: Promise< + typeof EXPIRED | typeof SUPERSEDED | { error: unknown } | null + >[] = [settled, timeout, piped]; + const { signal } = exchange; + const superseded = (): boolean => + signal.aborted && + (signal.reason as BindenAbortError).reason === "deadline"; + const { aborted } = signal; + if (!aborted) { + // A deadline that passes while it runs takes the reply over (but for the + // reply of the deadline itself, which runs once the signal is aborted) + outcomes.push( + exchange.aborted.then( + async (): Promise => { + if (superseded()) { + return SUPERSEDED; + } + // Not by another reason: e.g. a client that goes away leaves it + // to complete, or to expire + return timeout; + }, + ), + ); + } + let outcome = await Promise.race(outcomes); + clearTimeout(timer); + // Whatever has won (e.g. its rejection from a listener of the abort): a + // deadline that has passed while it ran has taken the reply over + if (!aborted && superseded()) { + outcome = SUPERSEDED; + } + state.abandoned = outcome !== null && outcome !== state.failure; + if (state.abandoned && state.failure !== null) { + // It had failed already (e.g. as the deadline passed) + late(state.failure.error); + } + return outcome; + } + + /** + * An empty `500 Internal Server Error`, without the headers of the body that + * a failed handler may have set + */ + async #opaque(context: Context): Promise { + const { response } = context; + // Once (`#representation` may list `Content-Type` as well) + for (const name of Binden.#representation) { + response.removeHeader(name); + } + await this.#empty(context, DefaultErrorCode); + } + + /** `503 Service Unavailable` to a request that arrives once closing */ + async #closing(context: Context): Promise { + context.response.setHeader("Connection", "close"); + await this.#empty(context, 503); + } + + /** An empty reply of the framework (a failure to send it is logged) */ + async #empty(context: Context, status: number): Promise { + // With the reason phrase of its status (see `#defaultErrorHandler()`) + context.response.statusMessage = ""; + context.response.status(status); + this.#guardCookies(context, status); + try { + await context.response.send(); + } catch (error: unknown) { + const message = "The error reply has not been sent"; + this.#logFailure(context, "debug", message, error, "send"); + Binden.#unsent(context); + } + } + + /** + * Has the cookies of a reply of the framework to an error serialised each + * in its own guard from now on (see `cookieGuards`): a cookie that fails + * (e.g. one whose `toString()` throws, which has failed the reply of the + * application already) is dropped and logged, and the error is still + * replied, with the other cookies + */ + #guardCookies(context: Context, status: number): void { + cookieGuards.set(context.response, (error: unknown) => { + const message = "A cookie has not been sent with the error reply"; + this.#logFailure(context, "warn", message, error, "send", status); + }); + } + + /** + * A reply of the framework has failed without ending the response (e.g. + * its `end()` has thrown once its head was committed): the connection is + * closed, rather than left open without a reply + */ + static #unsent({ response }: Context): void { + if (!response.writableEnded) { + response[kFramework](() => response.destroy()); + } + } + + /** + * 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). + * 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`. + */ + 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 level = status >= 500 ? "error" : "debug"; + const failed = (message: string): void => { + this.#logFailure(context, level, message, error, "middleware", status); + }; + if (response.writableEnded) { + // Sent (or being sent): destroying the socket would cut it + const ended = "The request has failed once its response was ended"; + failed(ended); + return; + } else if (response.started) { + // Part of the body may be sent (or compressed): ending the response would + // make a truncated body look complete + const started = + "The request has failed once its response was started. Destroying the socket"; + failed(started); + response[kFramework](() => response.destroy()); + return; + } + failed("The request has failed"); + + // The framing and representation of the body that is not sent. Only + // when set: Node.js remembers the removal of a header that is not, and + // once `Transfer-Encoding` and `Content-Length` (e.g. by a compression) + // have both been removed, it closes the connection to end the body. + for (const name of Binden.#representation) { + if (response.hasHeader(name)) { + response.removeHeader(name); + } + } + // With the reason phrase of its status, not one set for the reply that it + // replaces (which may even be one that Node.js refuses) + response.statusMessage = ""; + response.status(status); + if (error instanceof BindenError) { + response.set(error.headers); + } + // E.g. a middleware that has read a part of the body, then failed: Node.js + // does not read the rest of it, which would stall the connection + Binden.#closeUnread(context); + + // Sent like any other response: with its cookies (but those that fail, + // see `#guardCookies()`), 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) { + 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 { + this.#logFailure( + context, + "warn", + "`JSON.stringify` failed", + error, + "middleware", + status, + ); + return response.send(); + } + } else if (typeof message !== "string" || !message) { + this.#logFailure( + context, + "debug", + "`message` is not a valid string", + error, + "middleware", + status, + ); + return response.send(); + } + // Never the type of the body that it replaces (e.g. `text/html`) + return response + .setHeader("Content-Type", `${ct_text}; charset=utf-8`) + .send(message); + }; + + this.#guardCookies(context, status); + try { + await reply(); + } catch (failure: unknown) { + // E.g. the client went away + const message = "The error reply has not been sent"; + this.#logFailure(context, "debug", message, failure, "send"); + Binden.#unsent(context); + } + } + + async #runMiddleware( + middleware: IMiddleware, + context: Context, + exchange: Exchange, + ): Promise { + const { name } = middleware.constructor; + + if (middleware instanceof Middleware && middleware.disabled) { + logTo(context, "debug", "Middleware is disabled", { middleware: name }); + return context; + } + + try { + const result = + typeof middleware === "function" + ? middleware(context) + : middleware.run(context); + const next = + typeof property(result, "then") === "function" + ? await this.#race(result, context, exchange) + : result; + // A context made by its constructor (or the one of a subclass), told + // by its brand: never an object derived from one, nor a proxy of one + if (!isContext(next)) { + 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 formatStack( + new TypeError("A middleware returned the context of another request"), + ); + } + return next; + } catch (error: unknown) { + if (error === ABORTED) { + throw error; + } else if (middleware instanceof Middleware && middleware.ignore_errors) { + try { + const { log } = context; + record(log, "debug", "Middleware ignores errors", { + middleware: name, + ...this.#failure(context, error, "middleware"), + }); + } catch { + // Dropped (see `#logFailure()`) + } + return context; + } + + throw error; + } + } + + /** + * The result of a middleware, unless the request is aborted first: its late + * result is then ignored (and a late failure logged) + */ + 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.has_halted) { + const message = + "A middleware has failed after the request was aborted"; + this.#logFailure(context, "debug", message, error, "middleware"); + } + return { ok: false, error } as const; + }, + ); + const outcome = await Promise.race([ + settled, + exchange.halted.then(() => null), + ]); + // Whatever has won (e.g. its result settled from a listener of the + // abort): once the stack has stopped, its result is ignored + if (outcome === null || exchange.has_halted) { + throw ABORTED; + } else if (!outcome.ok) { + throw outcome.error; + } + return outcome.value; + } + + /** + * The headers of a body (its framing, representation and validators), which + * do not apply to an error that replaces it: the list that the libraries + * share (e.g. for the `406` of `@binden/compression`) + */ + static readonly #representation = bodyHeaders; + + /** + * The state of a request, from the `state` option: a factory that fails (or + * that does not return an object, or returns a promise) 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 property(state, "then") === "function") { + // E.g. of an `async` factory: its rejection is observed + Promise.resolve(state).catch(() => null); + throw formatStack( + new TypeError("The state of the request is a promise"), + ); + } else if (typeof state !== "object" || state === null) { + throw formatStack( + new TypeError("The state of the request is not an object"), + ); + } + return { state, failure: null }; + } catch (error: unknown) { + return { state: {}, failure: { error } }; + } + } + + /** + * The options of an application (see the constructor), each read once and + * checked, the request and the response options among them + */ + static #settings(options: unknown): ISettings { + const { + error_handler, + error_handler_timeout = 5000, + auto_head = true, + logger, + log_error_details = false, + access_log = false, + routing = {}, + state, + timeout = 0, + trust_proxy, + encoded_slashes, + allowed_hosts, + request_id, + query, + etag, + } = objectOf(options, "options") as IBindenOptions; + // 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); + Binden.#boolean("log_error_details", log_error_details); + if (typeof logger !== "undefined" && !isLogger(logger, true)) { + throw formatStack(new TypeError("`logger` is not a logger")); + } + if ( + typeof error_handler !== "undefined" && + typeof error_handler !== "function" + ) { + throw formatStack(new TypeError("`error_handler` is not a function")); + } else if (typeof state !== "undefined" && typeof state !== "function") { + throw formatStack(new TypeError("`state` is not a function")); + } else if (typeof routing !== "object" || (routing as unknown) === null) { + throw formatStack(new TypeError("`routing` is not an object")); + } + const { case_sensitive = true, strict = true } = routing; + const access = Binden.#accessOptions(access_log); + Binden.#boolean("routing.case_sensitive", case_sensitive); + Binden.#boolean("routing.strict", strict); + return { + error_handler: error_handler ?? null, + error_handler_timeout, + auto_head, + logger, + log_error_details, + access, + case_sensitive, + strict, + state: state ?? null, + timeout, + requests, + responses, + }; + } + + /** 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 formatStack( + 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 formatStack( + new TypeError("`access_log.level` is not `info` or `debug`"), + ); + } else if (path !== "template" && path !== "path") { + throw formatStack( + 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 of the layer that has started the + * reply, or `` for a reply of the framework), 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; + /** + * Whether Node.js accepts a write of the work that writes (whatever the + * backpressure): not once the response is ended (also when a compression + * reports it ended while its compressor writes) or destroyed, nor from the + * work that no longer owns the response (e.g. after its deadline), whose + * writes are ignored + */ + const accepts = (): boolean => + !Reflect.get(http.OutgoingMessage.prototype, "writableEnded", response) && + !response.destroyed && + response[kOperation]() !== null; + /** Counts the bytes of a chunk that Node.js has accepted */ + 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" + ) as BufferEncoding; + bytes += estimated.has(coding.toLowerCase()) + ? Buffer.from(chunk, coding).length + : Buffer.byteLength(chunk, coding); + } 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 => { + const accepted = accepts(); + // Counted once it has not thrown (e.g. for a chunk that is not one) + const result = Reflect.apply(write, response, args) as boolean; + if (accepted) { + count(args[0], args[1]); + } + return result; + }; + response.end = (...args: unknown[]): typeof response => { + const accepted = accepts(); + const result = Reflect.apply(end, response, args) as typeof response; + if (accepted) { + count(args[0], args[1]); + } + return result; + }; + + response[kListen]("close", () => { + const duration = performance.now() - start; + // By the private path of the logger (see `record()`): what getting it + // or a field throws (e.g. a getter of the application) drops the + // record, never out of the listener + try { + const { log } = context; + record(log, level, "The request has completed", { + method: request.method, + route: exchange.route ?? UNMATCHED, + // 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, + }); + } catch { + // Dropped + } + }); + } + + /** A boolean option */ + static #boolean(name: string, value: unknown): void { + if (typeof value !== "boolean") { + throw formatStack(new TypeError(`\`${name}\` is not a boolean`)); + } + } +} + +export default Binden; diff --git a/packages/binden/src/brands.ts b/packages/binden/src/brands.ts new file mode 100644 index 00000000..ca80f832 --- /dev/null +++ b/packages/binden/src/brands.ts @@ -0,0 +1,81 @@ +import type { BindenRequest } from "./request.js"; +import type { BindenResponse } from "./response.js"; +import type { Context } from "./context.js"; + +/** + * The private brand of a class (`#field in value`), which its static block + * gives: whether an object has been made by its constructor (or by the one of + * a subclass). Telling it runs no trap of a proxy, and a proxy, an object of + * another realm and one made with `Object.create()` of the prototype have + * none. + * + * @internal + */ +export type IBrand = (value: object) => boolean; + +// eslint-disable-next-line init-declarations -- set by the static block of `BindenRequest` +let request: IBrand; +// eslint-disable-next-line init-declarations -- set by the static block of `BindenResponse` +let response: IBrand; +// eslint-disable-next-line init-declarations -- set by the static block of `Context` +let context: IBrand; + +/** + * Sets the brand of the requests (see `isRequest()`), by the static block of + * `BindenRequest` + * + * @internal + */ +export function brandRequests(brand: IBrand): void { + request = brand; +} + +/** + * Sets the brand of the responses (see `isResponse()`), by the static block + * of `BindenResponse` + * + * @internal + */ +export function brandResponses(brand: IBrand): void { + response = brand; +} + +/** + * Sets the brand of the contexts (see `isContext()`), by the static block of + * `Context` + * + * @internal + */ +export function brandContexts(brand: IBrand): void { + context = brand; +} + +/** + * Whether a value is a request made by the constructor of `BindenRequest` + * (or of a subclass), by its brand (see `IBrand`) + * + * @internal + */ +export function isRequest(value: unknown): value is BindenRequest { + return typeof value === "object" && value !== null && request(value); +} + +/** + * Whether a value is a response made by the constructor of `BindenResponse` + * (or of a subclass), by its brand (see `IBrand`) + * + * @internal + */ +export function isResponse(value: unknown): value is BindenResponse { + return typeof value === "object" && value !== null && response(value); +} + +/** + * Whether a value is a context made by the constructor of `Context` (or of a + * subclass), by its brand (see `IBrand`) + * + * @internal + */ +export function isContext(value: unknown): value is Context { + return typeof value === "object" && value !== null && context(value); +} diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts new file mode 100644 index 00000000..4b735e37 --- /dev/null +++ b/packages/binden/src/context.ts @@ -0,0 +1,358 @@ +import { BindenError, type IBindenErrorOptions } from "./error.js"; +import type { + BindenResponse, + IClearCookie, + IHeadersValue, +} from "./response.js"; +import { Exchange, type IExchangeReport, formatStack } from "./exchange.js"; +import type { IRedirectOptions, IRedirectStatus } from "./redirect.js"; +import { brandContexts, isRequest, isResponse } from "./brands.js"; +import { childLogger, defaultLogger, logTo, safe, snapshot } from "./logger.js"; +import { described, formatting, objectOf } from "./formatting.js"; +import type { BindenRequest } from "./request.js"; +import type { ILogger } from "@binden/logger"; +import type { ISendFileOptions } from "./send-file.js"; +import type { IStreamLike } from "./stream-like.js"; +import type { Readable } from "node:stream"; + +/** 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; + /** + * The state of the request: used by the first context of the request only + * (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; +} + +/** 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, +> implements IBaseContext { + readonly #request: BindenRequest; + readonly #response: BindenResponse; + readonly #parent: ILogger; + readonly #exchange: Exchange; + #logger?: ILogger; + + static { + // A context made by the constructor (see `isContext()`) + brandContexts((value) => #request in value); + } + + /** + * 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, and for options that are not an object). Its + * request is a `BindenRequest` and its response a `BindenResponse`, each an + * instance made by its constructor (or the one of a subclass), told by its + * private brand: never a proxy of one (whose traps would run later, e.g. + * once the response is closed), an object of another realm or one made with + * `Object.create()` of its prototype. Its `report` is a function: anything + * else throws a `TypeError` that describes it. What reading the options + * throws (e.g. a revoked proxy) is formatted (see `formatting()`). + */ + public constructor(options: IContextOptions) { + // Each option is read once (e.g. by a getter) + const given = formatting(() => { + const read = objectOf(options, "options") as IContextOptions; + return { + request: read.request, + response: read.response, + logger: read.logger, + state: read.state, + report: read.report, + }; + }); + this.#request = given.request; + this.#response = given.response; + // Wrapped unless it is a logger of binden, told by its brand (see + // `safe()`): no code of the logger runs + this.#parent = formatting(() => safe(given.logger ?? defaultLogger())); + Context.#check(given); + this.#exchange = formatting(() => + Exchange.for(given.request, given.response, { + state: given.state, + report: + given.report ?? + ((message, error): void => { + const logged = { location: "cleanup", details: false } as const; + logTo(this, "error", message, { failure: snapshot(error, logged) }); + }), + }), + ); + } + + /** + * Checks the request, the response and `report` of the options, by their + * brands and types, without running code of the application (see + * `isRequest()`) + */ + static #check({ request, response, report }: IContextOptions): void { + let refusal: string | null = null; + if (!isRequest(request)) { + refusal = `\`request\` is not a BindenRequest: ${described(request)}`; + } else if (!isResponse(response)) { + refusal = `\`response\` is not a BindenResponse: ${described(response)}`; + } else if (typeof report !== "undefined" && typeof report !== "function") { + refusal = `\`report\` is not a function: ${described(report)}`; + } + if (refusal !== null) { + throw formatStack(new TypeError(refusal)); + } + } + + /** + * A child of the logger with the `trace_id` of the context, created when it + * is first used: a new logger of binden. 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. The framework writes its own records by its private path, + * never through the methods that the application may replace on it. + */ + public get log(): ILogger { + // By the private path of its parent (see `childLogger()`): never a + // `child()` that the application has replaced on it + this.#logger ??= childLogger(this.#parent, { trace_id: this.id }); + 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; + } + + /** Set once the request is handled: shared by every context of the request */ + public get done(): boolean { + 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); + } + + /** + * 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. A work that + * may no longer reply (e.g. an abandoned middleware) changes nothing. + */ + 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); + } + + /** + * 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()` (see `IStreamLike`) and set `done` */ + public async send( + data?: + Buffer | IStreamLike | Readable | Uint8Array | bigint | number | string, + ): Promise { + // Also once it is late: the response ignores it then, but releases a + // Node.js `Readable` (e.g. a stream of a file, which would stay open) + await this.#reply(() => this.response.send(data), true); + } + + /** + * 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" + ? this.response.json(data) + : this.response.json(data, stringify)); + }); + } + + /** 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)); + } + + /** 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; + } + + /** + * 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); + } + + /** + * 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, and + * `send` runs only when it is `late` too (the response ignores it as well) + */ + async #reply(send: () => Promise, late = false): Promise { + if (!this.#exchange.may_reply) { + logTo(this, "debug", "A late reply has been ignored"); + if (late) { + await send(); + } + return; + } else if (this.response.started) { + // E.g. a second `send()`: the response is not sent again + logTo(this, "warn", "The response has already been started"); + } + await send(); + this.done = true; + } +} diff --git a/packages/binden/src/delay.ts b/packages/binden/src/delay.ts new file mode 100644 index 00000000..38367ebc --- /dev/null +++ b/packages/binden/src/delay.ts @@ -0,0 +1,17 @@ +import { formatStack } from "./exchange.js"; +/** 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 formatStack(new TypeError(`\`${name}\` is not an integer`)); + } else if (value < min || value > MAX_DELAY) { + throw formatStack( + new RangeError(`\`${name}\` is not between ${min} and ${MAX_DELAY}`), + ); + } +} diff --git a/packages/binden/src/digests.ts b/packages/binden/src/digests.ts new file mode 100644 index 00000000..e948a752 --- /dev/null +++ b/packages/binden/src/digests.ts @@ -0,0 +1,17 @@ +/** + * The digest of the content of a message (RFC 9530, section 2): of the bytes + * that it sends. A reply without the body of the application (e.g. to + * `HEAD`, or a `304`), or with a part of it (a `206`), does not have it. + */ +export const contentDigests: readonly string[] = ["Content-Digest"]; + +/** + * The digests of the selected representation (`Repr-Digest` of RFC 9530, + * section 3, and `Digest` of RFC 3230): still those of a `HEAD`, a `304` or a + * `206` (appendix B.2), but not of a reply that replaces the representation + * (e.g. a `412`) + */ +export const representationDigests: readonly string[] = [ + "Digest", + "Repr-Digest", +]; diff --git a/packages/binden/src/error.ts b/packages/binden/src/error.ts new file mode 100644 index 00000000..d5507c89 --- /dev/null +++ b/packages/binden/src/error.ts @@ -0,0 +1,240 @@ +import { formatting, objectOf } from "./formatting.js"; +import { STATUS_CODES } from "node:http"; +import { formatStack } from "./exchange.js"; + +/** The options of a `BindenError` */ +export interface IBindenErrorOptions extends ErrorOptions { + /** 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; + /** + * 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; + +/** + * The values of an array, each read once by its index (an iterator of its + * own, e.g. of a subclass, could give others than those checked) + */ +function valuesOf(value: readonly unknown[]): unknown[] { + const { length } = value; + const values: unknown[] = []; + for (let index = 0; index < length; index += 1) { + values.push(value[index]); + } + return values; +} + +/** + * A frozen copy of valid headers (a `TypeError` otherwise), each value read + * once: the values checked are the ones kept + */ +function copyHeaders( + headers: unknown, +): Readonly> { + if ( + typeof headers !== "object" || + headers === null || + Array.isArray(headers) + ) { + throw formatStack(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 formatStack(new TypeError(`Invalid header name: ${name}`)); + } else if (forbidden.has(lower)) { + throw formatStack( + new TypeError(`The \`${name}\` header cannot be set by an error`), + ); + } else if (names.has(lower)) { + throw formatStack(new TypeError(`Duplicate header: ${name}`)); + } + const list = Array.isArray(value); + const values = list ? valuesOf(value) : [value]; + if ( + !values.every( + (item): item is string => + typeof item === "string" && fieldValue.test(item), + ) + ) { + throw formatStack( + new TypeError(`Invalid value of the \`${name}\` header`), + ); + } + names.add(lower); + copy.push([name, list ? Object.freeze(values) : (value as string)]); + } + return Object.freeze(Object.fromEntries(copy)); +} + +/** The options of a `BindenError`, read once and checked */ +interface IChecked { + readonly message: string | undefined; + readonly expose: boolean; + readonly json: Record | undefined; + readonly cause: unknown; + readonly code: string | undefined; + readonly headers: Readonly>; +} + +/** Whether a value is a plain object (of `Object`, or without a prototype) */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) { + return false; + } + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +/** + * An HTTP error: its status code (400 to 599) is the status of the reply. Its + * stack is formatted in its constructor, unless it is an instance of a + * subclass, which keeps the header that its own constructor gives it + */ +export class BindenError extends Error { + readonly #code: string | null; + readonly #headers: Readonly>; + readonly #expose: boolean; + 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, an + * `expose` that is not a boolean, a `json` that is not a plain object or a + * `message` that is not a string) throw a `TypeError`, and so do options + * that are not an object. The status is checked first (the default message + * is the one of the status), and each option is read once (`json` is + * copied then): what reading them throws (e.g. a revoked proxy, or a getter + * of `json`) is formatted (see `formatting()`). + */ + public constructor(status: number, options?: IBindenErrorOptions) { + const checked = BindenError.#checked(status, options); + + if (typeof checked.cause === "undefined") { + super(checked.message); + } else { + super(checked.message, { cause: checked.cause }); + } + + this.name = "BindenError"; + this.#code = checked.code ?? null; + this.#headers = checked.headers; + this.#expose = checked.expose; + this.#json = checked.json ?? null; + this.#status = status; + if (new.target === BindenError) { + // Formatted at once (see `formatStack()`), whoever creates it (binden, + // its packages or the application): kept, it retains nothing of its + // frames (e.g. the request whose URL it refuses). Not an instance of a + // subclass, whose own constructor may still give its stack its header + // (e.g. its `name`), once this one has returned + formatStack(this); + } + } + + /** + * The checked options of an error of the status (see the constructor): + * the status first, never converted (e.g. by the lookup of its default + * message), then the options, each read once + */ + static #checked(status: unknown, options: unknown): IChecked { + if (typeof status !== "number" || !Number.isInteger(status)) { + throw formatStack(new TypeError("Invalid status code")); + } else if (status > 599) { + throw formatStack(new RangeError("Status code is greater than 599")); + } else if (status < 400) { + throw formatStack(new RangeError("Status code is less than 400")); + } + return formatting(() => { + const { + message = STATUS_CODES[status], + expose = false, + json, + cause, + code, + headers = {}, + } = objectOf(options, "options", true) as IBindenErrorOptions; + if (typeof code !== "undefined" && typeof code !== "string") { + throw formatStack(new TypeError("`code` is not a string")); + } else if (typeof expose !== "boolean") { + throw formatStack(new TypeError("`expose` is not a boolean")); + } else if (typeof json !== "undefined" && !isPlainObject(json)) { + // E.g. an array or a string, which `json` is not sent as + throw formatStack(new TypeError("`json` is not a plain object")); + } else if ( + typeof message !== "undefined" && + typeof message !== "string" + ) { + throw formatStack(new TypeError("`message` is not a string")); + } + return { + message, + expose, + // Copied once (e.g. its getters run here): the getter of `json` copies + // the copy, and runs no code of the application + json: typeof json === "undefined" ? json : { ...json }, + cause, + code, + headers: copyHeaders(headers), + }; + }); + } + + /** The machine-readable reason of the error, if any */ + public get code(): string | null { + return this.#code; + } + + /** The headers that the default error handler sends with the error */ + public get headers(): Readonly> { + 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`, or `null`: a shallow copy of + * the one that the constructor has copied from its `json` + */ + 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 new file mode 100644 index 00000000..7724d35e --- /dev/null +++ b/packages/binden/src/exchange.ts @@ -0,0 +1,829 @@ +import { + type IFailureRoute, + type ILateFailures, + type ITokenIssuer, + onPipeFailure, +} from "./listeners.js"; +import { + type IPipeFailure, + kCancelling, + kFramework, + kListen, + kPipeFailure, + kRevoke, + owners, +} from "./seal.js"; +import type { BindenResponse } from "./response.js"; +import { EventEmitter } from "node:events"; +import type { IncomingMessage } from "node:http"; +import type { Socket } from "node:net"; +import { decided } from "./stream-like.js"; +import { described } from "./formatting.js"; +import { isProxy } from "node:util/types"; + +/** Why the work of a request has been abandoned */ +export type IAbortReason = "client_closed" | "deadline" | "shutdown"; + +/** + * Formats the stack of an error that binden, or one of its packages, has just + * created, before it throws it, rejects with it or hands it over (each one: + * e.g. the `TypeError` of an argument, the reason of an aborted signal, or + * the error that a send rejects with, or destroys its source with): V8 + * formats it once it is first read, and until then its frames hold their + * objects (e.g. the response, as the receiver of its methods), which an + * error kept by the application, or by a source (e.g. minipass, which keeps + * the one it is destroyed with), would retain as long as it lives. A plain + * `BindenError` formats itself. For the packages of binden only. + * + * @internal + */ +export function formatStack(error: T): T { + try { + Reflect.get(error, "stack"); + } catch { + // A `prepareStackTrace` of the application that throws: the stack is + // left to it + } + return error; +} + +/** + * The getter of `stack` of the engine: on the versions of Node.js that + * binden supports, every native error (also an instance of a subclass, a + * `DOMException` or an object of `Error.captureStackTrace()`) has it as an + * accessor of its own. It formats the stack once it is first read (by + * `Error.prepareStackTrace`, which may be of the application, and may throw: + * the stack is not formatted then), and returns it as formatted afterwards. + */ +function engineStack(): unknown { + const probe = {}; + Error.captureStackTrace(probe); + // An object in any case (none would be `{}`) + const descriptor = Object( + Reflect.getOwnPropertyDescriptor(probe, "stack"), + ) as object; + return Reflect.get(descriptor, "get"); +} + +/** The getter of `stack` of the engine (see `engineStack()`) */ +const nativeStack = engineStack(); + +/** + * Formats the stack of a value by the getter of the engine (see + * `engineStack()`): whether it is formatted. A `prepareStackTrace` of the + * application may throw: the stack is not formatted then, and Node.js would + * run it again. + */ +function formats(value: unknown): boolean { + try { + Reflect.get(Object(value) as object, "stack", value); + return true; + } catch { + return false; + } +} + +/** + * Where the `stack` of a value is found (see `readsSafely()`): its own, or of + * the nearest prototype that has one, as its descriptor and the object that + * holds it, `null` without one, or `false` through a proxy, whose traps are + * code of the application (never run here) + */ +function stackOf( + value: object, +): false | { holder: object; descriptor: PropertyDescriptor } | null { + for ( + let link: object | null = value; + link !== null; + link = Reflect.getPrototypeOf(link) + ) { + if (isProxy(link)) { + return false; + } + const descriptor = Reflect.getOwnPropertyDescriptor(link, "stack"); + if (typeof descriptor !== "undefined") { + return { holder: link, descriptor }; + } + } + return null; +} + +/** + * Whether Node.js reads the `stack` of a value without running code of the + * application, as it does to destroy a `Readable` with it (before the + * stream is destroyed, and again once its `_destroy()` has called back): a + * value whose `stack` (its own, or of the nearest prototype that has one) is + * a value, a value without a `stack`, or one whose `stack` is the getter of + * the engine (see `engineStack()`), read here first: once formatted, its + * stack is kept, and Node.js reads it as it is. Never through a proxy (its + * traps are code of the application), nor by a getter of the application, + * which is not called (it may return a string here, and throw once Node.js + * reads it). Formatting runs code of the application (e.g. a getter of its + * `name` that V8 reads, or a `prepareStackTrace`), which may change where + * the stack is found (e.g. define a getter of its own): the prototype chain + * and the descriptor are checked again afterwards, without formatting (the + * stack is kept by then), so that the verdict is the one of the value that + * Node.js reads. For the packages of binden only. + * + * @internal + */ +export function readsSafely(value: unknown): boolean { + const object = Object(value) as object; + const found = stackOf(object); + if (found === null || found === false) { + return found === null; + } else if ("value" in found.descriptor) { + return true; + } else if ( + Reflect.get(found.descriptor, "get") !== nativeStack || + !formats(value) + ) { + return false; + } + // Again, once formatted: its own value (e.g. of a getter that has replaced + // it), or the getter of the engine on the same object, which has kept it + const again = stackOf(object); + return ( + again === null || + (again !== false && + ("value" in again.descriptor || + (again.holder === found.holder && + Reflect.get(again.descriptor, "get") === nativeStack))) + ); +} + +/** The message of a carrier (see `carrierOf()`) */ +const carried = "A stream has failed"; + +/** `Error.prototype` of the engine (a property that cannot be changed) */ +const errorPrototype = Error.prototype; + +/** + * What a `Readable` is destroyed with in place of a value whose `stack` + * Node.js cannot read safely (see `readsSafely()`): an `Error` (an object of + * `Error.prototype`) that carries it as its `cause`, decided as well (see + * `decided()`), made with its `stack` as a value of its own that can be + * neither written nor redefined, its header: no `prepareStackTrace` of the + * application ever runs for it (one that redefines or freezes the `stack` of + * what it formats would leave a carrier that Node.js cannot read, or that + * binden cannot fix), it holds no frames (nothing of the request or of the + * response, whoever keeps it), and Node.js reads its `stack` as it is. For + * the packages of binden only. + * + * @internal + */ +export function carrierOf(cause: unknown): Error { + const carrier = Object.create(errorPrototype, { + message: { configurable: true, value: carried, writable: true }, + cause: { configurable: true, value: cause, writable: true }, + stack: { value: `Error: ${carried}` }, + }) as Error; + decided(carrier); + return carrier; +} + +/** The reasons of an abort (see `IAbortReason`) */ +const abortReasons: ReadonlySet = new Set([ + "client_closed", + "deadline", + "shutdown", +]); + +/** The reason of `context.signal` once the request is aborted */ +export class BindenAbortError extends Error { + readonly #reason: IAbortReason; + + /** + * The abort of a request, for the reason: anything but one of the reasons + * throws a `TypeError`, which describes it without converting it (see + * `described()`) + */ + public constructor(reason: IAbortReason) { + if (!abortReasons.has(reason)) { + throw formatStack( + new TypeError(`Unknown abort reason: ${described(reason)}`), + ); + } + 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; + } +} + +/** 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; +} + +type IPhase = "finalizing" | "finished" | "running"; + +/** The greatest delay of a Node.js timer */ +const MAX_DELAY = 2 ** 31 - 1; + +const exchanges = new WeakMap(); + +/** The methods of `EventEmitter` of every emitter (e.g. of a connection) */ +const intrinsic = EventEmitter.prototype; + +/** + * A connection whose termination its exchanges observe (see + * `Exchange.#observe()`): the exchanges whose responses are not closed yet, + * and its one `close` listener + */ +interface ITransport { + readonly exchanges: Set; + readonly closed: IClosed; +} + +/** The `close` listener of a connection (see `ITransport`) */ +type IClosed = () => void; + +const transports = 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 implements ITokenIssuer { + readonly #response: BindenResponse; + readonly #controller: AbortController; + readonly #state: object; + readonly #cleanups: (() => unknown)[]; + readonly #finished: Promise; + readonly #report: IExchangeReport; + readonly #aborted: Promise; + readonly #halted = Promise.withResolvers(); + /** The tokens of the works that may mutate the response (see `token()`) */ + readonly #live = new Set(); + /** The tokens of the works that may no longer mutate it */ + readonly #revoked = new WeakSet(); + /** + * The failure routes of the tokens that it has issued (see `token()`): + * cleared once it finishes, so that what keeps a token (e.g. a timer + * created in the context of its work) no longer keeps the exchange + */ + readonly #routes: IFailureRoute[] = []; + /** What a late failure of a pipe of its works does (see `onLateFailure()`) */ + readonly #late: ILateFailures = { report: null }; + /** Settles once the revoked works have been told (see `kRevoke`) */ + #delivered: Promise = Promise.resolve(); + /** The token of the middleware stack (see `stack()`), once created */ + #stack: object | null = null; + #halting = false; + /** Whether its connection terminates (see `terminating`) */ + #terminating = false; + /** The connection of the request, whose termination it observes */ + readonly #connection: Socket | null; + #phase: IPhase; + #sealed: boolean; + #reply: object | null; + #done: boolean; + #timer: NodeJS.Timeout | null; + #params: Readonly>; + #mount_path: string; + #mount_template: string; + #route: string | null; + + private constructor( + request: IncomingMessage, + response: BindenResponse, + { state = {}, report }: IExchangeOptions, + ) { + this.#response = response; + const { socket } = request as { socket: Socket | null }; + // E.g. a request without a socket has no connection to observe + this.#connection = socket instanceof EventEmitter ? socket : null; + this.#controller = new AbortController(); + this.#state = state; + this.#cleanups = []; + this.#report = report ?? Exchange.#ignore; + this.#aborted = new Promise((resolve) => { + this.#controller.signal.addEventListener( + "abort", + () => { + this.halt(); + resolve(); + }, + { once: true }, + ); + }); + this.#phase = "running"; + this.#sealed = false; + this.#reply = null; + this.#done = false; + this.#timer = null; + 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: the real one (never the one of an abandoned work) + this.#finished = new Promise((resolve) => { + response[kListen]("close", () => { + this.#unobserve(); + if (!response.writableFinished) { + this.abort("client_closed"); + } + resolve(this.#finish()); + }); + }); + this.#observe(); + } + + /** + * Observes the termination of the connection of the request (the `close` + * of its socket: the client has gone away, Node.js has aborted the + * responses that it still had, or a shutdown has destroyed it) until the + * response is closed: by one listener of the connection, for every exchange + * of its requests (e.g. pipelined) whose response is not closed yet. Never + * the `close` of the request itself, which comes once its body has been + * read, while the connection lives on. + */ + #observe(): void { + const connection = this.#connection; + if (connection === null) { + return; + } + let transport = transports.get(connection); + if (typeof transport === "undefined") { + const observed = new Set(); + const closed = (): void => { + transports.delete(connection); + intrinsic.removeListener.call(connection, "close", closed); + for (const exchange of observed) { + exchange.#terminate(); + } + }; + transport = { exchanges: observed, closed }; + transports.set(connection, transport); + intrinsic.on.call(connection, "close", closed); + } + transport.exchanges.add(this); + } + + /** + * The response is closed: its connection is no longer observed for it (nor + * at all, once no other response of the connection is open) + */ + #unobserve(): void { + const connection = this.#connection as Socket; + const transport = transports.get(connection); + if (transport?.exchanges.delete(this) === true) { + if (transport.exchanges.size === 0) { + transports.delete(connection); + intrinsic.removeListener.call(connection, "close", transport.closed); + } + } + } + + /** + * The connection of the request has closed: a response that is not closed + * yet is destroyed with the rights of the framework (also once it is + * sealed, e.g. a complete reply that waits for the response before it, or + * the reply of a deadline, which Node.js would destroy without an owner), so + * that it is finalized once, on every version of Node.js: one that has no + * socket (it waits for another response of the connection) is closed on + * the next tick, by Node.js or else (Node.js 24.14) by the framework + */ + #terminate(): void { + this.#terminating = true; + const response = this.#response; + response[kFramework](() => response.destroy()); + process.nextTick(() => { + if (!response.closed) { + Reflect.set(response, "_closed", true); + response.emit("close"); + } + }); + } + + /** + * The token of the work of the latest `reply()` (see `reply()`), until + * the exchange is sealed again: `null` otherwise + */ + public get current_reply(): object | null { + return this.#reply; + } + + /** + * Whether the connection of the request terminates: it has closed (see + * `#terminate()`), or the server shuts down (see `abort()`), also while + * the abort listeners run, before its socket is destroyed + */ + public get terminating(): boolean { + return this.#terminating; + } + + /** + * 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: BindenResponse, + options: IExchangeOptions = {}, + ): Exchange { + const found = exchanges.get(request); + if (typeof found === "undefined") { + const exchange = new Exchange(request, response, options); + exchanges.set(request, exchange); + return exchange; + } else if (found.#response !== response) { + throw formatStack( + new TypeError("The response is not the one of the request"), + ); + } + return found; + } + + /** The exchange of a request, if it has one */ + public static find(request: IncomingMessage): Exchange | null { + 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; + } + + /** `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; + } + + /** Sets the parameters of the current match (a frozen copy) */ + 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; + } + + /** Sets the matched mount prefixes, with their values */ + 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; + } + + /** Sets the matched mount prefixes, as registered */ + public set mount_template(template: string) { + 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; + }; + } + + /** + * The route that the access log names: the templates of the matched mounts + * and of the layer that has started the reply (`null` for none: the access + * log names `` for a reply of the framework) + */ + public get route(): string | null { + 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; + } + + /** 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.allows(owners.getStore()); + } + + /** + * Whether the work of an owner (see `owners`: a token of the exchange, or + * `undefined` for a work of no request) may reply: anything but a revoked + * token before the exchange is sealed, then only the latest `reply()` + */ + public allows(owner: object | undefined): boolean { + if (typeof owner !== "undefined" && this.#revoked.has(owner)) { + return false; + } + return !this.#sealed || (this.#reply !== null && owner === this.#reply); + } + + /** + * From now on only the framework replies (e.g. once the deadline has + * passed): the works of the exchange (its stack, and any earlier + * `reply()`) may no longer reply, and are told so (see `kRevoke`), with the + * failure of a pipe of one of them, if any. The pending sends are then + * cancelled at once with its error (see `kCancelling`): the framework + * carries the cancellation out once they have been told (see `delivered`), + * and a send that settles in between rejects with that error, never as a + * send that is ignored. + */ + public seal(failure: IPipeFailure | null = null): void { + this.#sealed = true; + this.#reply = null; + if (failure !== null) { + this.#response[kCancelling](failure.error); + } + this.#revoke([...this.#live], failure); + } + + /** + * One work of the exchange may no longer reply (e.g. a pipe of the stack + * that fails once the error handler runs), and is told so (see `kRevoke`): + * the others keep their rights + */ + public revoke(failure: IPipeFailure): void { + if (this.#live.has(failure.owner)) { + this.#revoke([failure.owner], failure); + } + } + + /** Revokes works of the exchange: they are told so on the next tick */ + #revoke(tokens: object[], failure: IPipeFailure | null): void { + for (const token of tokens) { + this.#live.delete(token); + this.#revoked.add(token); + } + if (tokens.length > 0) { + const delivered = this.#response[kRevoke](tokens, failure); + this.#delivered = this.#delivered.then(() => delivered); + } + } + + /** + * Settles once the works revoked so far have been told so (see + * `kRevoke`): the work that replaces them starts afterwards (never rejects) + */ + public get delivered(): Promise { + return this.#delivered; + } + + /** + * A token of a work of the exchange (e.g. its middleware stack): the work + * run with it (see `owners`) may reply until it is revoked + */ + public token(): object { + const token = {}; + this.#live.add(token); + // A pipe of the work that fails (e.g. the end of its `pipeline()`) + // fails it on this response, whatever response it pipes into (a new + // token always gets its route) + const route = onPipeFailure( + token, + this, + (error, source) => this.#response[kPipeFailure](token, error, source), + this.#late, + ) as IFailureRoute; + if (this.#phase === "running") { + this.#routes.push(route); + } else { + // Its exchange has finished already: a failure of its pipes is late + route.fail = null; + } + return token; + } + + /** + * Sets how a late failure of a pipe of a work of the exchange (once it has + * finished, see `finished`) is reported (e.g. logged at `debug`): the + * report must not hold the request (e.g. its context), which would then be + * kept as long as anything keeps a token of its works + */ + public onLateFailure(report: (error: unknown) => void): void { + this.#late.report = report; + } + + /** Whether a token is of a work of the exchange (see `token()`), also once revoked */ + public issued(token: object): boolean { + return this.#live.has(token) || this.#revoked.has(token); + } + + /** + * The token of the middleware stack (see `token()`), created once: the + * deadline is dispatched as its work (see `setTimeout()`) + */ + public stack(): object { + this.#stack ??= this.token(); + return this.#stack; + } + + /** Runs the reply of the framework: the work it starts may reply (see `may_reply`) */ + public reply(fn: () => T): T { + const reply = this.token(); + this.#reply = reply; + return owners.run(reply, fn); + } + + /** + * The middleware stack stops (e.g. a pipe of it has failed), although the + * signal is not aborted (the deadline still applies) + */ + public halt(): void { + this.#halting = true; + (this.#halted.resolve as () => void)(); + } + + /** Settles once the stack stops: the signal is aborted, or `halt()` (never rejects) */ + public get halted(): Promise { + return this.#halted.promise; + } + + /** Whether the stack stops (see `halted`) */ + public get has_halted(): boolean { + return this.#halting; + } + + /** 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. The + * exchange is sealed before a `deadline` is dispatched (see `seal()`): the + * abort listeners and the continuations of the work that is abandoned (e.g. + * a `catch` that replies) then see a response that they may no longer change. + */ + public abort(reason: IAbortReason): boolean { + if (this.#controller.signal.aborted) { + return false; + } + this.#clearTimer(); + if (reason === "deadline") { + this.seal(); + } else if (reason === "shutdown") { + // Before its listeners run (see `terminating`) + this.#terminating = true; + } + // Formatted at once: a source destroyed with it (e.g. a send cancelled at + // the deadline) may keep it, and must not retain the exchange + this.#controller.abort(formatStack(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. + * Ignored, without checking `ms`, for a work that may no longer reply + * (e.g. a stack that has failed): the deadline is of the work that replaces + * it. Its abort runs as the work of the stack (see `stack()`), whatever + * work has started the deadline: the abort listeners of the stack, and + * what they start, are of the abandoned work (see `kRevoke`). + */ + public setTimeout(ms: number): void { + if (!this.may_reply) { + return; + } else if (!Number.isInteger(ms)) { + throw formatStack(new TypeError("The timeout is not an integer")); + } else if (ms < 0 || ms > MAX_DELAY) { + throw formatStack( + new RangeError(`The timeout is not between 0 and ${MAX_DELAY}`), + ); + } else if (this.#phase !== "running") { + throw formatStack(new TypeError("The request has been completed")); + } + this.#clearTimer(); + if (ms > 0 && !this.#controller.signal.aborted) { + this.#timer = setTimeout(() => { + this.#timer = null; + const stack = this.#stack; + if (stack === null) { + this.abort("deadline"); + } else { + owners.run(stack, () => 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 formatStack(new TypeError("The cleanup is not a function")); + } else if (this.#phase !== "running") { + throw formatStack(new TypeError("The request has been completed")); + } + this.#cleanups.push(cleanup); + } + + async #finish(): Promise { + this.#phase = "finalizing"; + this.#clearTimer(); + // Its works may no longer fail it: a later failure of their pipes is late + for (const route of this.#routes.splice(0)) { + route.fail = null; + } + 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/src/formatting.ts b/packages/binden/src/formatting.ts new file mode 100644 index 00000000..7a8dd825 --- /dev/null +++ b/packages/binden/src/formatting.ts @@ -0,0 +1,80 @@ +import { formatStack } from "./exchange.js"; + +/** + * Runs the body of an entry point of binden, or of one of its packages (e.g. + * what reads or calls an argument: the properties of an object, or a + * function): what it throws is formatted (see `formatStack()`) before the + * caller gets it, such as the `TypeError` that V8 creates while the frames of + * binden are on the stack for a revoked proxy, a destructuring of `null`, or + * a class constructor called as a function. An error of the application's own + * (e.g. of a getter) is thrown as it is (formatted too), and anything thrown + * that is not an error is passed on as it is. A hot path (e.g. a write) uses + * the same `try`/`catch` inline, without the closure. For the packages of + * binden only. + * + * @internal + */ +export function formatting(run: () => T): T { + try { + return run(); + } catch (error: unknown) { + throw formatStack(error as Error); + } +} + +/** + * A value that an argument error of binden refuses, as its message shows it, + * without converting it: a string, a number, a boolean, `undefined` or `null` + * as it is, a `bigint` with its `n`, and anything else by its type (`(a + * symbol)`, `(a function)` or `(an object)`), so that neither a conversion + * throws (e.g. of a `Symbol`, or of an object without a primitive value: an + * error that V8 would create while the frames of the call are on the stack) + * nor code of the application runs (e.g. a `toString()`). For the packages of + * binden only. + * + * @internal + */ +export function described(value: unknown): string { + switch (typeof value) { + case "string": + return value; + case "number": + case "boolean": + return `${value}`; + case "bigint": + return `${value}n`; + case "undefined": + return "undefined"; + case "symbol": + return "(a symbol)"; + case "function": + return "(a function)"; + case "object": + default: + return value === null ? "null" : "(an object)"; + } +} + +/** + * An options object or the init of a header (`name` in the message), checked + * before any of its properties is read: an object (an array too), or + * `undefined` where it is `optional` (an empty object then, for the + * defaults). Anything else (e.g. `null`, a string, a number, or a function) + * is refused with a `TypeError` that describes it (see `described()`), + * formatted (see `formatStack()`): destructuring would read a primitive as an + * object without the options, and fail (with an error of V8) on `null`. + */ +export function objectOf( + value: unknown, + name: string, + optional = false, +): object { + if (optional && typeof value === "undefined") { + return {}; + } else if (typeof value !== "object" || value === null) { + throw formatStack( + new TypeError(`\`${name}\` is not an object: ${described(value)}`), + ); + } + return value; +} diff --git a/packages/binden/src/framing.ts b/packages/binden/src/framing.ts new file mode 100644 index 00000000..3ac8ecda --- /dev/null +++ b/packages/binden/src/framing.ts @@ -0,0 +1,188 @@ +import { type OutgoingHttpHeader, OutgoingMessage } from "node:http"; +import { described } from "./formatting.js"; +import { formatStack } from "./exchange.js"; +import { trimOws } from "./headers/list.js"; + +/** + * The fields of Node.js that frame a response, which an attempt to commit + * its head may change before Node.js refuses it (e.g. `_storeHeader()` has + * decided to close the connection when it refuses a `Trailer` of a body that + * is not chunked): its body (`_hasBody`, `_contentLength`, `chunkedEncoding`, + * `useChunkedEncodingByDefault`, `strictContentLength`), its connection + * (`_last`, `shouldKeepAlive`, `maxRequestsOnConnectionReached`, + * `_defaultKeepAlive`, `_keepAliveTimeout`), its `Date` (`sendDate`), and the + * headers that the application has removed (`_removedConnection`, + * `_removedContLen`, `_removedTE`). For the packages of binden only. + * + * @internal + */ +export const framingFields: readonly string[] = Object.freeze([ + "_contentLength", + "_defaultKeepAlive", + "_hasBody", + "_keepAliveTimeout", + "_last", + "_removedConnection", + "_removedContLen", + "_removedTE", + "chunkedEncoding", + "maxRequestsOnConnectionReached", + "sendDate", + "shouldKeepAlive", + "strictContentLength", + "useChunkedEncodingByDefault", +]); + +/** + * The counter of the bytes of the body that Node.js has accepted, which + * `strictContentLength` checks: its own symbol `kBytesWritten`, found once at + * load on a message of its own (none on a version of Node.js without it). + * Node.js counts a chunk before it commits the head, also when it refuses it. + */ +export const framingCounters: readonly symbol[] = Object.freeze( + Object.getOwnPropertySymbols(new OutgoingMessage()).filter( + (key) => key.description === "kBytesWritten", + ), +); + +/** + * The framing state of a `BindenResponse` before an attempt to commit its + * head (see `kSaveFraming`): its status and reason phrase, the values of + * `framingFields` and of the byte counter of Node.js (see + * `strictContentLength`), and its headers (their names in order and case, and + * copies of their values). Its lifecycle, its listeners and the output it has + * written are not part of it. For the packages of binden only. + * + * @internal + */ +export interface IFramingState { + /** The status code of the response */ + readonly status: number; + /** The reason phrase of the response */ + readonly reason: string | undefined; + /** + * The values of `framingFields` (by name) and of the byte counter of + * Node.js (by its symbol) + */ + readonly fields: readonly (readonly [string | symbol, unknown])[]; + /** The headers: their names (in order, with their case) and their values */ + readonly headers: readonly (readonly [string, number | string | string[]])[]; + /** The generation of the framing headers (see `BindenResponse`) */ + readonly generation: number; +} + +/** + * The key of the method of `BindenResponse` that saves its framing state: + * `response[kSaveFraming]()` (see `IFramingState`). A library that commits the + * head itself (e.g. `@binden/compression`) saves it before, and restores it + * when Node.js refuses the head. For the packages of binden only. + * + * @internal + */ +export const kSaveFraming: unique symbol = Symbol("binden.saveFraming"); + +/** + * The key of the method of `BindenResponse` that restores a framing state + * saved by `kSaveFraming`: `response[kRestoreFraming](state)` sets its + * headers, then its fields (removing a header sets some of them), its status + * and its reason phrase, with the rights of the framework. For the packages of + * binden only. + * + * @internal + */ +export const kRestoreFraming: unique symbol = Symbol("binden.restoreFraming"); + +/** + * Why the `Content-Length` values of a head are refused, `null` when they + * are not: a list (e.g. `2, 3`, also `2, 2`, alone or as an element of an + * array), which Node.js cannot read, even of members that agree; or + * elements of an array (which Node.js sends a line of each of, and frames + * the body by the last) that are not all one same length (each of digits + * only), so that a recipient may read either of them (RFC 9110, section + * 8.6). `BindenResponse` refuses them before anything is committed (see + * `kValidateHead`), where Node.js would send them. + */ +export function lengthRefusal(value: unknown): string | null { + const elements = ( + Array.isArray(value) ? value : typeof value === "undefined" ? [] : [value] + ).map((element) => trimOws(String(element))); + if (elements.some((element) => element.includes(","))) { + return "Content-Length is a list"; + } + const [first = ""] = elements; + return elements.length > 1 && + !elements.every( + (element) => + /^\d+$/u.test(element) && + Number.isSafeInteger(Number(element)) && + Number(element) === Number(first), + ) + ? "Content-Length values disagree" + : null; +} + +/** + * The values of a `Content-Length` (see `framingLength()`), copied once (an + * array read by its indexes, so that a hole is an element), each a number or + * a string: anything else (also `undefined` or `null` as an element, `true`, + * or an object whose `toString()` gives a length) is refused with a + * `TypeError` of `ERR_HTTP_INVALID_HEADER_VALUE` that describes it (see + * `described()`), before anything converts it. Node.js would send such a + * value converted, while binden could not frame the body by it. What reading + * them throws (e.g. a revoked proxy) is formatted (see `formatStack()`). + * `undefined` is no value (the header is not set). For the packages of binden + * only. + * + * @internal + */ +export function lengthValues(value: unknown): readonly (number | string)[] { + const values: unknown[] = []; + try { + if (Array.isArray(value)) { + const { length } = value; + for (let index = 0; index < length; index += 1) { + values.push(value[index]); + } + } else if (typeof value !== "undefined") { + values.push(value); + } + } catch (error: unknown) { + throw formatStack(error as Error); + } + for (const element of values) { + if (typeof element !== "number" && typeof element !== "string") { + throw Object.assign( + formatStack( + new TypeError(`Invalid Content-Length: ${described(element)}`), + ), + { code: "ERR_HTTP_INVALID_HEADER_VALUE" }, + ); + } + } + return values as (number | string)[]; +} + +/** + * The length of the body that the `Content-Length` of a response (its value, + * see `getHeader()`) declares, as Node.js reads it (the last element of an + * array, which agrees with the others), `null` without a valid one: none for + * an empty array, nor for values that `BindenResponse` refuses (see + * `lengthRefusal()`). binden frames the body by it, and `@binden/compression` + * decides by it. A value (or an element, also a hole) that is neither a + * number nor a string is refused with a `TypeError` of + * `ERR_HTTP_INVALID_HEADER_VALUE`, before anything converts it (see + * `lengthValues()`), never read as no length. For the packages of binden + * only. + * + * @internal + */ +export function framingLength( + value: OutgoingHttpHeader | undefined, +): number | null { + const values = lengthValues(value); + if (lengthRefusal(Array.isArray(value) ? values : values.at(0)) !== null) { + return null; + } + const length = Number(values.at(-1) ?? Number.NaN); + return Number.isSafeInteger(length) && length >= 0 ? length : null; +} diff --git a/packages/binden/src/headers/accept-charset.ts b/packages/binden/src/headers/accept-charset.ts new file mode 100644 index 00000000..20a36ca2 --- /dev/null +++ b/packages/binden/src/headers/accept-charset.ts @@ -0,0 +1,138 @@ +import { + fieldLinesOf, + isQvalue, + offersOf, + preferred, + qvalueRefusal, + stringOf, + token, + weighted, +} from "./list.js"; +import { formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.js"; + +/** An element of `Accept-Charset` */ +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; +} + +/** 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`, and so does an init that is not an object. What reading it + * throws (e.g. a revoked proxy) is formatted (see `formatting()`). + */ + public constructor(init: IAcceptCharset) { + // Each value is read once (e.g. by a getter), and converted once when it + // is not a string (see `stringOf()`): the values checked are the ones kept + const checked = formatting(() => + AcceptCharset.#read(objectOf(init, "init") as IAcceptCharset), + ); + const refusal = AcceptCharset.#refusal(checked); + if (refusal !== null) { + throw formatStack(new TypeError(refusal)); + } + this.#charset = checked.charset; + this.#q_value = checked.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}`; + } + + /** + * 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. An input that is neither a string, an array of strings nor + * `undefined` throws a `TypeError`. + */ + public static fromString(input?: string[] | string): AcceptCharset[] { + return formatting(() => + AcceptCharset.#parse(fieldLinesOf(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 (also one that is not a + * string), offers that are not an array and a header of another type than + * `fromString()` takes throw a `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + return formatting(() => { + const list = offersOf( + offers, + "Invalid charset", + (offer) => token.test(offer) && offer !== "*", + ); + const header = fieldLinesOf(input); + if (typeof header === "undefined") { + return list.at(0) ?? false; + } + return preferred(list, AcceptCharset.#parse(header), (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 }) => { + // Checked first: an invalid element (e.g. of a client) creates no + // error, whose stack would be formatted (see `formatStack()`) + const init = { charset, q_value }; + return AcceptCharset.#refusal(init) === null + ? [new AcceptCharset(init)] + : []; + }); + } + + /** The values of an init, each read once, the charset converted once */ + static #read({ charset, q_value = null }: IAcceptCharset): { + readonly charset: string; + readonly q_value: number | null; + } { + return { charset: stringOf(charset), q_value }; + } + + /** + * Why an element is invalid (see the constructor), `null` when it is valid + */ + static #refusal({ charset, q_value = null }: IAcceptCharset): string | null { + if (!token.test(charset)) { + return `Invalid charset: ${charset}`; + } else if (!isQvalue(q_value)) { + return qvalueRefusal(q_value); + } + return null; + } +} + +export default AcceptCharset; diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts new file mode 100644 index 00000000..5f43462f --- /dev/null +++ b/packages/binden/src/headers/accept-encoding.ts @@ -0,0 +1,195 @@ +import { described, formatting, objectOf } from "../formatting.js"; +import { + fieldLinesOf, + isQvalue, + offersOf, + preferred, + qvalue, + qvalueRefusal, + token, +} from "./list.js"; +import { formatStack } from "../exchange.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 { + /** 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; +} + +/** 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; + + /** + * A known content coding (or `*`) and its weight (a qvalue): invalid ones + * throw a `TypeError` (an unknown coding described without converting it, + * see `described()`), and so does an init that is not an object. What + * reading it throws (e.g. a revoked proxy) is formatted (see + * `formatting()`). + */ + public constructor(init: IAcceptEncoding) { + // Each value is read once (e.g. by a getter): the values checked are the + // ones kept + const checked = formatting(() => + AcceptEncoding.#read(objectOf(init, "init") as IAcceptEncoding), + ); + if (!AcceptEncoding.#encodings.includes(checked.encoding)) { + throw formatStack( + new TypeError(`Unknown content coding: ${described(checked.encoding)}`), + ); + } else if (!isQvalue(checked.q_value)) { + throw formatStack(new TypeError(qvalueRefusal(checked.q_value))); + } + this.#encoding = checked.encoding; + this.#q_value = checked.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. An input that is neither a string, an array of strings nor + * `undefined` throws a `TypeError`. + */ + public static fromString(string?: string[] | string): AcceptEncoding[] { + return formatting(() => + AcceptEncoding.#parse(fieldLinesOf(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 + * (also one that is not a string), offers that are not an array and a + * header of another type than `fromString()` takes throw a `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + return formatting(() => { + const list = offersOf( + offers, + "Invalid content coding", + (offer) => token.test(offer) && offer !== "*", + ); + const header = fieldLinesOf(input); + if (typeof header === "undefined") { + return list.at(0) ?? false; + } + const elements = AcceptEncoding.#parse(header).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(list, elements, ({ coding, implicit }, offer) => { + const match = coding === AcceptEncoding.#canonical(offer); + if (implicit) { + return match ? -1 : null; + } + return coding === "*" ? 0 : match ? 1 : null; + }); + }); + } + + /** The values of an init, each read once */ + static #read({ encoding, q_value = null }: IAcceptEncoding): { + readonly encoding: IAcceptEncodings; + readonly q_value: number | null; + } { + return { encoding, q_value }; + } + + /** 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 []; + } + + // Content codings and the `q` parameter are case-insensitive (RFC 9110) + return (Array.isArray(input) ? input.join(",") : input) + .split(",") + .map<[string, number | null] | null>((part) => { + const [coding = "", ...params] = part + .split(";") + .map((param) => param.trim()); + // `q = value`, split at its first `=` (a regular expression with `\s*` on + // both sides of `=` would backtrack polynomially on long runs of spaces) + const weight = params.find((param) => { + const equals = param.indexOf("="); + return ( + equals !== -1 && param.slice(0, equals).trim().toLowerCase() === "q" + ); + }); + if (typeof weight === "undefined") { + return [coding.toLowerCase(), null]; + } + // A weight is a qvalue (RFC 9110, section 12.4.2), or the element is invalid + const value = weight.slice(weight.indexOf("=") + 1).trim(); + return qvalue.test(value) + ? [coding.toLowerCase(), Number(value)] + : null; + }) + .filter((e) => e !== null); + } + + static readonly #encodings: readonly string[] = [ + "gzip", + "x-gzip", + "compress", + "deflate", + "br", + "zstd", + "*", + "identity", + ]; +} + +export default AcceptEncoding; diff --git a/packages/binden/src/headers/accept-language.ts b/packages/binden/src/headers/accept-language.ts new file mode 100644 index 00000000..64cea0ce --- /dev/null +++ b/packages/binden/src/headers/accept-language.ts @@ -0,0 +1,149 @@ +import { + fieldLinesOf, + isQvalue, + offersOf, + preferred, + qvalueRefusal, + stringOf, + weighted, +} from "./list.js"; +import { formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.js"; + +/** An element of `Accept-Language` */ +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; +} + +/** `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`, and so does an init that is not an object. What reading it + * throws (e.g. a revoked proxy) is formatted (see `formatting()`). + */ + public constructor(init: IAcceptLanguage) { + // Each value is read once (e.g. by a getter), and converted once when it + // is not a string (see `stringOf()`): the values checked are the ones kept + const checked = formatting(() => + AcceptLanguage.#read(objectOf(init, "init") as IAcceptLanguage), + ); + const refusal = AcceptLanguage.#refusal(checked); + if (refusal !== null) { + throw formatStack(new TypeError(refusal)); + } + this.#language = checked.language; + this.#q_value = checked.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}`; + } + + /** + * 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. An input that is neither a string, an array of strings nor + * `undefined` throws a `TypeError`. + */ + public static fromString(input?: string[] | string): AcceptLanguage[] { + return formatting(() => + AcceptLanguage.#parse(fieldLinesOf(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 (also one that is not a + * string), offers that are not an array and a header of another type than + * `fromString()` takes throw a `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + return formatting(() => { + const list = offersOf(offers, "Invalid language tag", (offer) => + tag.test(offer), + ); + const header = fieldLinesOf(input); + if (typeof header === "undefined") { + return list.at(0) ?? false; + } + return preferred(list, AcceptLanguage.#parse(header), (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 }) => { + // Checked first: an invalid element (e.g. of a client) creates no + // error, whose stack would be formatted (see `formatStack()`) + const init = { language, q_value }; + return AcceptLanguage.#refusal(init) === null + ? [new AcceptLanguage(init)] + : []; + }); + } + + /** The values of an init, each read once, the language converted once */ + static #read({ language, q_value = null }: IAcceptLanguage): { + readonly language: string; + readonly q_value: number | null; + } { + return { language: stringOf(language), q_value }; + } + + /** + * Why an element is invalid (see the constructor), `null` when it is valid + */ + static #refusal({ + language, + q_value = null, + }: IAcceptLanguage): string | null { + if (language !== "*" && !tag.test(language)) { + return `Invalid language range: ${language}`; + } else if (!isQvalue(q_value)) { + return qvalueRefusal(q_value); + } + return null; + } +} + +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..dbcf2ca7 --- /dev/null +++ b/packages/binden/src/headers/accept.ts @@ -0,0 +1,248 @@ +import { described, formatting, objectOf } from "../formatting.js"; +import { + fieldLinesOf, + fieldValue, + isQvalue, + offersOf, + preferred, + quote, + qvalueRefusal, + stringOf, + token, + weighted, +} from "./list.js"; +import { formatStack } from "../exchange.js"; + +/** An element of `Accept`: a media range, its parameters and weight */ +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; + /** The weight: a qvalue from 0 to 1 (none by default, which weighs 1) */ + 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`, + * and so does an init or parameters that are not an object. What reading + * them throws (e.g. a revoked proxy) is formatted (see `formatting()`). + */ + public constructor(init: IAccept) { + // Each value is read once (e.g. by a getter), and so are the parameters, + // each converted once when it is not a string (see `stringOf()`): the + // values checked are the ones kept + const checked = formatting(() => { + const { + type, + parameters = {}, + q_value = null, + } = objectOf(init, "init") as IAccept; + return Accept.#checked({ type, parameters, q_value }); + }); + if (typeof checked === "string") { + throw formatStack(new TypeError(checked)); + } + this.#type = checked.type; + this.#parameters = checked.parameters; + this.#q_value = checked.q_value; + } + + /** The media range (in lowercase when parsed) */ + 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); + } + + /** 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)}`, + ); + 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. + * An input that is neither a string, an array of strings nor `undefined` + * throws a `TypeError`. + */ + public static fromString(input?: string[] | string): Accept[] { + return formatting(() => + Accept.#parse(fieldLinesOf(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 (also one that is not a string), offers that are not an array + * and a header of another type than `fromString()` takes throw a + * `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + return formatting(() => { + const types = new Map(); + const list = offersOf(offers, "Invalid media type", (offer) => { + const media = Accept.#offer(offer); + types.set(offer, media as IMediaType); + return media !== null; + }); + const header = fieldLinesOf(input); + if (typeof header === "undefined") { + return list.at(0) ?? false; + } + return preferred(list, Accept.#parse(header), (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 }) => { + // Checked first: an invalid element (e.g. of a client) creates no + // error, whose stack would be formatted (see `formatStack()`) + const init = { + type: value.toLowerCase(), + parameters: Object.fromEntries(parameters), + q_value, + }; + return typeof Accept.#checked(init) === "string" + ? [] + : [new Accept(init)]; + }); + } + + /** + * A valid element (see the constructor): its media range, its parameters by + * their names in lowercase (the first one of a name wins), each read once, + * and its weight, the strings converted once (see `stringOf()`), or why the + * element is invalid + */ + static #checked({ type, parameters = {}, q_value = null }: IAccept): + | { + readonly type: string; + readonly parameters: ReadonlyMap; + readonly q_value: number | null; + } + | string { + const range = stringOf(type); + const [main = "", subtype = "", ...rest] = range.split("/"); + if ( + rest.length > 0 || + !token.test(main) || + !token.test(subtype) || + (main === "*" && subtype !== "*") + ) { + return `Invalid media range: ${range}`; + } else if (!isQvalue(q_value)) { + return qvalueRefusal(q_value); + } else if ( + typeof parameters !== "object" || + (parameters as unknown) === null + ) { + return `\`parameters\` is not an object: ${described(parameters)}`; + } + const all = new Map(); + for (const [name, given] of Object.entries(parameters)) { + const key = name.toLowerCase(); + if (!token.test(key) || key === "q") { + return `Invalid parameter name: ${name}`; + } + const value = stringOf(given); + if (!fieldValue.test(value)) { + return `Invalid value of the parameter ${name}`; + } else if (!all.has(key)) { + all.set(key, value); + } + } + return { type: range, parameters: all, q_value }; + } + + /** A media type, without wildcards nor a weight, or `null` */ + static #offer(offer: string): IMediaType | null { + // 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 === "*" + ) { + return null; + } + 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. The names of + * the parameters are case-insensitive, and so is the value of `charset` + * only (RFC 9110, section 8.3.1): the other values are compared as they are. + */ + 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; + const same = (name: string, value: string): boolean => { + const offered = media.parameters.get(name); + return name === "charset" + ? offered?.toLowerCase() === value.toLowerCase() + : offered === value; + }; + if ( + (level > 0 && type !== media.type) || + (level > 1 && subtype !== media.subtype) || + parameters.some(([name, value]) => !same(name, value)) + ) { + return null; + } + return level * 1000 + parameters.length; + } +} + +export default Accept; diff --git a/packages/binden/src/headers/authorization.ts b/packages/binden/src/headers/authorization.ts new file mode 100644 index 00000000..d6e52fbe --- /dev/null +++ b/packages/binden/src/headers/authorization.ts @@ -0,0 +1,161 @@ +import { checkFieldValue, fieldValue, stringOf } from "./list.js"; +import { described, formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.js"; + +/** The supported authentication schemes, in their usual casing */ +export type IAuthenticationType = + | "AWS4-HMAC-SHA256" + | "Basic" + | "Bearer" + | "Digest" + | "HOBA" + | "Mutual" + | "OAuth" + | "SCRAM-SHA-1" + | "SCRAM-SHA-256" + | "vapid"; + +/** 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; +} + +/** The `Authorization` header (RFC 9110, section 11.6.2) */ +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` (an unsupported scheme described + * without converting it, see `described()`), and so does an init that is + * not an object. What reading it throws (e.g. a revoked proxy) is formatted + * (see `formatting()`). + */ + public constructor(init: IAuthorization) { + // Each value is read once (e.g. by a getter), and the credentials + // converted once when they are not a string (see `stringOf()`): the + // values checked are the ones kept + const checked = formatting(() => + Authorization.#read(objectOf(init, "init") as IAuthorization), + ); + const refusal = Authorization.#refusal(checked); + if (refusal !== null) { + throw formatStack(new TypeError(refusal)); + } + this.#type = checked.type; + this.#credentials = checked.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). An input that is neither a string nor `undefined` + * throws a `TypeError`. + */ + public static fromString(input?: string): Authorization | null { + checkFieldValue(input); + // auth-scheme [ 1*SP ( token68 / #auth-param ) ] (RFC 9110, section 11.4), + // split at the first space (a regular expression with ` +` before `.*` + // would backtrack polynomially on long runs of spaces) + const value = input?.trim() ?? ""; + const space = value.indexOf(" "); + const head = space === -1 ? value : value.slice(0, space); + const scheme = /^[!#$%&'*+.^_`|~\w-]+$/u.test(head) ? head : ""; + const rest = space === -1 ? "" : value.slice(space + 1); + // The scheme is case-insensitive (RFC 9110, section 11.1) + const type = Authorization.#types.get(scheme.toLowerCase()); + + if (typeof type === "undefined") { + return null; + } + + const credentials = rest.trim(); + const init = { type, credentials: credentials === "" ? null : credentials }; + // Checked first: invalid credentials (e.g. with control characters) + // create no error, whose stack would be formatted (see `formatStack()`) + return Authorization.#refusal(init) === null + ? new Authorization(init) + : null; + } + + /** The values of an init, each read once, the credentials converted once */ + static #read({ type, credentials = null }: IAuthorization): { + readonly type: IAuthenticationType; + readonly credentials: string | null; + } { + return { + type, + credentials: credentials === null ? null : stringOf(credentials), + }; + } + + /** + * Why a header is invalid (see the constructor), `null` when it is valid + */ + static #refusal({ type, credentials = null }: IAuthorization): string | null { + if (!Authorization.isValidType(type)) { + return `Unsupported authentication scheme: ${described(type)}`; + } else if ( + credentials !== null && + (credentials === "" || + credentials !== credentials.trim() || + !fieldValue.test(credentials)) + ) { + return "Invalid credentials"; + } + return null; + } + + /** 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 { + return ( + typeof input === "string" && + Authorization.#types.get(input.toLowerCase()) === input + ); + } +} + +export default Authorization; diff --git a/packages/binden/src/headers/content-encoding.ts b/packages/binden/src/headers/content-encoding.ts new file mode 100644 index 00000000..9ecd798f --- /dev/null +++ b/packages/binden/src/headers/content-encoding.ts @@ -0,0 +1,85 @@ +import { described, formatting, objectOf } from "../formatting.js"; +import { checkFieldValue } from "./list.js"; +import { formatStack } from "../exchange.js"; + +/** The known content codings */ +export type IEncodings = + "br" | "compress" | "deflate" | "gzip" | "x-gzip" | "zstd"; + +/** A coding of `Content-Encoding` */ +export interface IContentEncoding { + /** A known content coding, in lowercase */ + encoding: IEncodings; +} + +/** A coding of the `Content-Encoding` header (RFC 9110, section 8.4) */ +export class ContentEncoding implements IContentEncoding { + readonly #encoding: IEncodings; + + /** + * A known content coding (in lowercase), or a `TypeError` (the coding + * described without converting it, see `described()`), also for an init + * that is not an object. What reading it throws (e.g. a revoked proxy) is + * formatted (see `formatting()`). + */ + public constructor(init: IContentEncoding) { + const encoding = formatting( + () => (objectOf(init, "init") as IContentEncoding).encoding, + ); + if (!ContentEncoding.#encodings.has(encoding)) { + throw formatStack( + new TypeError(`Unknown content coding: ${described(encoding)}`), + ); + } + 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; + } + + /** + * 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). An input that is neither a string nor + * `undefined` throws a `TypeError`. + */ + public static fromString(input?: string): ContentEncoding[] | null { + checkFieldValue(input); + if (typeof input === "undefined") { + return []; + } + + // 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().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", + "zstd", + ]); +} + +export default ContentEncoding; diff --git a/packages/binden/src/headers/content-range.ts b/packages/binden/src/headers/content-range.ts new file mode 100644 index 00000000..2fdb9594 --- /dev/null +++ b/packages/binden/src/headers/content-range.ts @@ -0,0 +1,138 @@ +import { type IRange, Range } from "./range.js"; +import { formatting, objectOf } from "../formatting.js"; +import { checkFieldValue } from "./list.js"; +import { formatStack } from "../exchange.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 | "*"; +} + +/** The `Content-Range` header (RFC 9110, section 14.4) */ +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, which needs a numeric `size`), + * and the size of the content: anything else throws a `TypeError`, and so + * does an init that is not an object. What reading it throws (e.g. a + * revoked proxy) is formatted (see `formatting()`). + */ + public constructor(init: IContentRange) { + // Each value is read once (e.g. by a getter): the values checked are the + // ones kept + const checked = formatting(() => + ContentRange.#read(objectOf(init, "init") as IContentRange), + ); + const refusal = ContentRange.#refusal(checked); + if (refusal !== null) { + throw formatStack(new TypeError(refusal)); + } + + super({ start: checked.start, end: checked.end }); + this.#size = checked.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}` + : `${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. An input that is + * neither a string nor `undefined` throws a `TypeError`. + */ + public static fromString(input?: string): [] | [ContentRange] { + checkFieldValue(input); + const { + first, + last, + size = "", + length, + } = /^[ \t]*bytes (?:(?\d+)-(?\d+)\/(?\d+|\*)|\*\/(?\d+))[ \t]*$/iu.exec( + input ?? "", + )?.groups ?? {}; + + const init: IContentRange | null = + typeof length === "string" + ? { size: Number(length) } + : typeof first === "string" && typeof last === "string" + ? { + start: Number(first), + end: Number(last), + size: size === "*" ? size : Number(size), + } + : null; + // Checked first: an invalid range (e.g. `bytes 1-0/5`, or a position that + // is not a safe integer) creates no error, whose stack would be formatted + // (see `formatStack()`). A valid one is a valid `Range` as well. + return init !== null && ContentRange.#refusal(init) === null + ? [new ContentRange(init)] + : []; + } + + /** The values of an init, each read once */ + static #read({ start = null, end = null, size }: IContentRange): { + readonly start: number | null; + readonly end: number | null; + readonly size: number | "*"; + } { + return { start, end, size }; + } + + /** + * Why a content range is invalid (see the constructor), `null` when it is + * valid (and so is its range) + */ + static #refusal({ + start = null, + end = null, + size, + }: IContentRange): string | null { + if (typeof start === "number" && !Number.isSafeInteger(start)) { + return "`start` is not an integer"; + } else if (typeof start === "number" && start < 0) { + return "`start` is less than zero"; + } else if (typeof end === "number" && !Number.isSafeInteger(end)) { + return "`end` is not an integer"; + } else if (typeof end === "number" && end < 0) { + return "`end` is less than zero"; + } else if (size !== "*" && !Number.isSafeInteger(size)) { + return "`size` is not an integer"; + } else if (size !== "*" && size < 0) { + return "`size` is less than zero"; + } else if (typeof end === "number" && size !== "*" && end >= size) { + return "`end` is greater than `size`"; + } else if (start === null && end === null && size === "*") { + // `bytes *\/*` is not a content range (RFC 9110, section 14.4) + return "An unsatisfied range needs a `size`"; + } else if (typeof start !== "number" && typeof end === "number") { + return "`start` is missing"; + } else if (typeof start === "number" && typeof end !== "number") { + return "`end` is missing"; + } else if ( + typeof start === "number" && + typeof end === "number" && + end < start + ) { + return "`end` is less than `start`"; + } + return null; + } +} + +export default ContentRange; diff --git a/packages/binden/src/headers/content-type.ts b/packages/binden/src/headers/content-type.ts new file mode 100644 index 00000000..e64dc04a --- /dev/null +++ b/packages/binden/src/headers/content-type.ts @@ -0,0 +1,228 @@ +import { + checkFieldValue, + fieldValue, + split, + stringOf, + unquote, +} from "./list.js"; +import { described, formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.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`. A value of another type is refused. + */ + 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>; +} + +const token = "[!#$%&'*+.^_`|~\\w-]+"; + +/** `type/subtype` */ +const mediaType = new RegExp(`^${token}/${token}$`, "u"); + +/** The name of a parameter */ +const parameterName = new RegExp(`^${token}$`, "u"); + +/** + * A valid media type (converted once, see `stringOf()`), its options, and all + * its parameters by name + */ +interface IChecked { + readonly type: string; + charset: string | null; + boundary: string | null; + readonly parameters: ReadonlyMap; +} + +/** The `Content-Type` header (RFC 9110, section 8.3) */ +export class ContentType implements IContentType { + readonly #type: string; + readonly #charset: string | null; + readonly #boundary: string | null; + readonly #parameters: ReadonlyMap; + + /** + * A media type (`type/subtype`, of tokens) with its parameters: a token as + * name, and a value that a field value can carry (no control characters but + * tabs, nothing above `\xFF`), sent as a quoted string when it is not a + * token. Invalid ones throw a `TypeError`, and so does an init or parameters + * that are not an object, or a `charset` that is not a string (nor `null`), + * described without converting it. What reading them throws (e.g. a + * revoked proxy) is formatted (see `formatting()`). + */ + public constructor(init: IContentType) { + // Each value is read once (e.g. by a getter), and so are the parameters + // (see `#checked()`): the values checked are the ones kept + const checked = formatting(() => { + const { + type, + charset = null, + boundary = null, + parameters = {}, + } = objectOf(init, "init") as IContentType; + return ContentType.#checked({ type, charset, boundary, parameters }); + }); + if (typeof checked === "string") { + throw formatStack(new TypeError(checked)); + } + this.#type = checked.type; + this.#boundary = checked.boundary; + this.#charset = checked.charset; + this.#parameters = checked.parameters; + } + + /** 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; + } + + /** Every parameter, by its name in lowercase */ + public get parameters(): Record { + 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, + ...[...this.#parameters].map( + ([name, value]) => `${name}=${ContentType.#quote(value)}`, + ), + ].join("; "); + } + + /** + * `type/subtype *( OWS ";" OWS parameter )` (RFC 9110, section 8.3.1), or + * `null` (invalid or missing). An input that is neither a string nor + * `undefined` throws a `TypeError`. + */ + public static fromString(input?: string): ContentType | null { + checkFieldValue(input); + const [rawType = "", ...rest] = split(input ?? "", ";"); + const type = rawType.trim().toLowerCase(); + + // 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 (parameterName.test(name) && !parameters.has(name)) { + const value = unquote(parameter.slice(index + 1).trim()); + parameters.set(name, name === "charset" ? value.toLowerCase() : value); + } + } + + const init = { + type, + charset: parameters.get("charset") ?? null, + boundary: parameters.get("boundary") ?? null, + parameters: Object.fromEntries(parameters), + }; + // Checked first: an invalid header (e.g. not a media type, or + // `multipart/form-data` without a boundary) creates no error, whose stack + // would be formatted (see `formatStack()`) + return typeof ContentType.#checked(init) === "string" + ? null + : new ContentType(init); + } + + /** + * The state of a valid media type (see the constructor): its `charset`, its + * `boundary` and all its parameters by their names in lowercase (the first + * one of a name wins, but the options), the media type and the values of + * the parameters converted once when they are not strings (see + * `stringOf()`), or why it is invalid (e.g. a `charset` of another type than + * a string, refused by its type, for every media type) + */ + static #checked({ + type, + charset = null, + boundary = null, + parameters = {}, + }: IContentType): IChecked | string { + const media = stringOf(type); + if (!mediaType.test(media)) { + return `Invalid media type: ${media}`; + } else if ( + typeof parameters !== "object" || + (parameters as unknown) === null + ) { + return `\`parameters\` is not an object: ${described(parameters)}`; + } else if (charset !== null && typeof charset !== "string") { + // Never converted, nor dropped + return `\`charset\` is not a string: ${described(charset)}`; + } + const all = new Map(); + for (const [name, value] of Object.entries(parameters)) { + const key = name.toLowerCase(); + if (!all.has(key)) { + all.set(key, stringOf(value)); + } + } + // `boundary` (of `multipart/form-data` only, in any case) and `charset` + // (of the other types) are the options themselves + const state: IChecked = { + type: media, + charset: null, + boundary: null, + parameters: all, + }; + if (media.toLowerCase() === "multipart/form-data") { + if (typeof boundary !== "string" || !boundary) { + return "`boundary` is missing"; + } + state.boundary = boundary; + all.set("boundary", boundary); + all.delete("charset"); + } else { + all.delete("boundary"); + if (typeof charset === "string" && charset) { + state.charset = charset; + all.set("charset", charset); + } else { + all.delete("charset"); + } + } + for (const [name, value] of all) { + if (!parameterName.test(name)) { + return `Invalid parameter name: ${name}`; + } else if (!fieldValue.test(value)) { + return `Invalid value of the parameter ${name}`; + } + } + return state; + } + + /** A token as is, anything else as a quoted string */ + static #quote(value: string): string { + return /^[!#$%&'*+.^_`|~\w-]+$/u.test(value) + ? value + : `"${value.replaceAll(/(?["\\])/gu, "\\$")}"`; + } +} + +export default ContentType; diff --git a/packages/binden/src/headers/cookie.ts b/packages/binden/src/headers/cookie.ts new file mode 100644 index 00000000..46ba0e1f --- /dev/null +++ b/packages/binden/src/headers/cookie.ts @@ -0,0 +1,315 @@ +import { checkFieldValue, stringOf, timeOf } from "./list.js"; +import { described, formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.js"; + +/** 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; +} + +/** 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"]); + +/** A cookie of `Cookie`, or of `Set-Cookie` with its attributes */ +export class Cookie implements ICookie { + readonly #key: string; + readonly #value: string; + readonly #expires: Date | null; + readonly #max_age: number | null; + readonly #domain: string | null; + readonly #path: string | null; + readonly #secure: boolean; + readonly #http_only: boolean; + 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`, and so does an init that is not an + * object. What reading it throws (e.g. a revoked proxy) is formatted (see + * `formatting()`). + */ + public constructor(init: ICookie) { + // Each attribute is read once (e.g. by a getter), and its value converted + // once when it is not a string (see `stringOf()`): the values checked are + // the ones kept, and so is the copy of the date (whose methods could tell + // another time than its own, e.g. of a subclass) + const checked = formatting(() => + Cookie.#read(objectOf(init, "init") as ICookie), + ); + const refusal = Cookie.#refusal(checked); + if (refusal !== null) { + throw formatStack(new TypeError(refusal)); + } + + this.#key = checked.key; + this.#value = checked.value; + this.#max_age = checked.max_age; + this.#domain = checked.domain; + this.#path = checked.path; + this.#secure = checked.secure; + this.#http_only = checked.http_only; + this.#same_site = checked.same_site; + this.#partitioned = checked.partitioned; + this.#expires = checked.expires; + } + + /** + * The attributes of an init, each read once, with their defaults, the + * strings converted once (see `stringOf()`) and the date copied (see + * `#copy()`) + */ + static #read({ + key, + value, + expires = null, + max_age = null, + domain = null, + path = "/", + secure = false, + http_only = true, + same_site = "Lax", + partitioned = false, + }: ICookie): Required & { + readonly expires: Date | null; + readonly max_age: number | null; + } { + return { + key: stringOf(key), + value: stringOf(value), + expires: Cookie.#copy(expires), + max_age, + domain: domain === null ? null : stringOf(domain), + path: path === null ? null : stringOf(path), + same_site, + secure: Boolean(secure), + http_only: Boolean(http_only), + partitioned: Boolean(partitioned), + }; + } + + /** + * The copy of the `Expires` of a cookie: a `Date` (also of a subclass) by + * its time, which the engine reads (see `timeOf()`), anything else `null`. + * A proxy of a `Date`, which has no time of its own, is refused with a + * `TypeError`. Called inside the boundary of the constructor (see + * `formatting()`), which formats what checking it throws (e.g. of a revoked + * proxy). + */ + static #copy(expires: unknown): Date | null { + if (!(expires instanceof Date)) { + return null; + } + const time = timeOf(expires); + if (time === null) { + throw formatStack(new TypeError("The Expires is not a Date")); + } + return new Date(time); + } + + /** + * Why a cookie is invalid (see the constructor), `null` when it is valid: + * a refused value is described without converting it (see `described()`) + */ + static #refusal({ + key, + value, + expires = null, + max_age = null, + domain = null, + path = "/", + same_site = "Lax", + }: ICookie): string | null { + // An invalid name or attribute could add attributes to `Set-Cookie` + if (!token.test(key)) { + return `Invalid cookie name: ${key}`; + } else if (!cookieValue.test(value)) { + return "Invalid cookie value"; + } else if (max_age !== null && !Number.isSafeInteger(max_age)) { + return `Invalid cookie Max-Age: ${described(max_age)}`; + } else if (expires instanceof Date && Number.isNaN(expires.getTime())) { + return "Invalid cookie Expires"; + } else if (domain !== null && !domainValue.test(domain)) { + return `Invalid cookie Domain: ${domain}`; + } else if (path !== null && !attributeValue.test(path)) { + return `Invalid cookie Path: ${path}`; + } else if (!sameSite.has(same_site)) { + return `Invalid cookie SameSite: ${described(same_site)}`; + } + return 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-") || + this.key.startsWith("__Host-") || + this.same_site === "None" || + this.#partitioned || + this.#secure + ); + } + + /** 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 += + typeof this.max_age === "number" + ? `; Max-Age=${this.max_age}` + : this.expires + ? `; Expires=${this.expires.toUTCString()}` + : ""; + output += typeof this.domain === "string" ? `; Domain=${this.domain}` : ""; + output += typeof this.path === "string" ? `; Path=${this.path}` : ""; + output += this.secure ? `; Secure` : ""; + output += this.http_only ? `; HttpOnly` : ""; + output += `; SameSite=${this.same_site}`; + output += this.partitioned ? "; Partitioned" : ""; + 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. An input that is neither a + * string nor `undefined` throws a `TypeError`. + */ + public static fromString(input?: string): Cookie[] { + checkFieldValue(input); + if (typeof input !== "string") { + return []; + } + + const output: Cookie[] = []; + // The names of the cookies found so far: linear in the number of cookies + const names = new Set(); + + 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 = + raw.length >= 2 && raw.startsWith(`"`) && raw.endsWith(`"`) + ? raw.slice(1, -1) + : raw; + + // An empty value is a value (RFC 6265, section 4.1.1) + if (index > 0 && !names.has(key)) { + // Checked first: an invalid cookie (e.g. of a client) is skipped, + // and creates no error, whose stack would be formatted (see + // `formatStack()`) + const init = { key, value }; + if (Cookie.#refusal(init) === null) { + output.push(new Cookie(init)); + names.add(key); + } + } + } + + return output; + } +} + +export default Cookie; diff --git a/packages/binden/src/headers/forwarded.ts b/packages/binden/src/headers/forwarded.ts new file mode 100644 index 00000000..d7953cd1 --- /dev/null +++ b/packages/binden/src/headers/forwarded.ts @@ -0,0 +1,200 @@ +import { + fieldLinesOf, + fieldValue, + quote, + split, + splitFromEnd, + unquote, +} from "./list.js"; +import { formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.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; +} + +/** An element of the `Forwarded` header (RFC 7239) */ +export class Forwarded implements IForwarded { + readonly #for: string; + readonly #by?: string | null; + readonly #host?: string | null; + 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`, and so does an init that is + * not an object. What reading it throws (e.g. a revoked proxy) is formatted + * (see `formatting()`). + */ + public constructor(init: IForwarded) { + // Each value is read once (e.g. by a getter): the values checked are the + // ones kept + const parameters = formatting(() => { + const given = objectOf(init, "init") as IForwarded; + return { + for: given.for, + by: given.by ?? null, + host: given.host ?? null, + secret: given.secret ?? null, + proto: given.proto ?? null, + }; + }); + const refusal = Forwarded.#refusal(parameters); + if (refusal !== null) { + throw formatStack(new TypeError(refusal)); + } + this.#for = parameters.for; + this.#by = parameters.by; + this.#host = parameters.host; + this.#secret = parameters.secret; + this.#proto = parameters.proto; + } + + /** 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; + } + + /** The element, with its values that are not tokens as quoted strings */ + public toString(): string { + const { by, host, secret, proto } = this; + 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()`). An input that is + * neither a string, an array of strings nor `undefined` throws a + * `TypeError`. + */ + public static fromString(input?: readonly string[] | string): Forwarded[] { + return formatting(() => + Forwarded.#elements(fieldLinesOf(input)).filter( + (element): element is Forwarded => element !== null, + ), + ); + } + + /** + * The elements of the header (a field value, or its field lines) 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, so a field line is read from its end, where a + * quoted string that is not terminated (e.g. sent by a client) invalidates + * the element that has it and those before it only. An input that is + * neither a string, an array of strings nor `undefined` throws a + * `TypeError`. + */ + public static parse( + input?: readonly string[] | string, + ): (Forwarded | null)[] { + return formatting(() => Forwarded.#elements(fieldLinesOf(input))); + } + + /** The elements of the header, `null` for an invalid one (see `parse()`) */ + static #elements(input?: readonly string[] | string): (Forwarded | null)[] { + const lines = typeof input === "string" ? [input] : (input ?? []); + return lines.flatMap((line) => + splitFromEnd(line, ",") + .filter((element) => !/^[\s;]*$/u.test(element)) + .map((element) => Forwarded.#parseElement(element)), + ); + } + + 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)); + } + const init = { + 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, + }; + // Checked first: an invalid element (e.g. with control characters in a + // quoted string) creates no error, whose stack would be formatted (see + // `formatStack()`) + return Forwarded.#refusal(init) === null ? new Forwarded(init) : null; + } + + /** + * Why the parameters of an element are invalid (see the constructor), + * `null` when they are valid + */ + static #refusal({ + by, + host, + secret, + proto, + ..._ + }: IForwarded): string | null { + 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))) { + return `Invalid \`${name}\` parameter`; + } + } + return null; + } +} + +export default Forwarded; diff --git a/packages/binden/src/headers/http-date.ts b/packages/binden/src/headers/http-date.ts new file mode 100644 index 00000000..f1836c3e --- /dev/null +++ b/packages/binden/src/headers/http-date.ts @@ -0,0 +1,78 @@ +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; + // Not `Date.UTC()`, which reads the years 0 to 99 as 1900 to 1999 + const parsed = new Date(0); + parsed.setUTCFullYear(y, m, d); + parsed.setUTCHours(h, min, s, 0); + + // 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 new file mode 100644 index 00000000..19938e28 --- /dev/null +++ b/packages/binden/src/headers/if-modified-since.ts @@ -0,0 +1,72 @@ +import { checkFieldValue, timeOf } from "./list.js"; +import { described, formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.js"; +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; +} + +/** The `If-Modified-Since` header (RFC 9110, section 13.1.3) */ +export class IfModifiedSince implements IIfModifiedSince { + readonly #date: Date; + + /** + * A date that is an HTTP-date (from the year 0 to 9999): a `Date` (by its + * time, which the engine reads, never its methods), or, as `new Date()` + * reads them, a number or a string. An invalid one throws a `TypeError`, + * and so does any other value (described without converting it, see + * `described()`) or an init that is not an object. What reading it throws + * (e.g. a revoked proxy) is formatted (see `formatting()`). + */ + public constructor(init: IIfModifiedSince) { + const value = formatting(() => + IfModifiedSince.#copy((objectOf(init, "init") as IIfModifiedSince).date), + ); + const year = value.getUTCFullYear(); + if (Number.isNaN(year)) { + throw formatStack(new TypeError("Invalid date")); + } else if (year < 0 || year > 9999) { + throw formatStack(new TypeError("The date is not an HTTP-date")); + } + this.#date = value; + } + + /** A copy of a date: of a `Date`, a number or a string (a `TypeError` otherwise) */ + static #copy(date: unknown): Date { + if (typeof date === "number" || typeof date === "string") { + return new Date(date); + } + const time = timeOf(date); + if (time === null) { + throw formatStack( + new TypeError(`The date is not a Date: ${described(date)}`), + ); + } + return new Date(time); + } + + /** 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(); + } + + /** + * An HTTP-date, or `null` (a recipient ignores any other value). An input + * that is neither a string nor `undefined` throws a `TypeError`. + */ + public static fromString(input?: string): IfModifiedSince | null { + checkFieldValue(input); + const date = parseHttpDate(input?.trim() ?? ""); + return date ? new IfModifiedSince({ date }) : null; + } +} + +export default IfModifiedSince; diff --git a/packages/binden/src/headers/list.ts b/packages/binden/src/headers/list.ts new file mode 100644 index 00000000..1b425d72 --- /dev/null +++ b/packages/binden/src/headers/list.ts @@ -0,0 +1,335 @@ +import { described } from "../formatting.js"; +import { formatStack } from "../exchange.js"; + +/** 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` + */ +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) + */ +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; +} + +/** + * Splits a field value on the separators that are not in a quoted string, as + * `split()` does, but read from its end: a quoted string that is not + * terminated swallows what is before it, not what is after it (e.g. the + * elements that proxies append after the one of a client). The parts are in + * their order. + */ +export function splitFromEnd(input: string, separator: string): string[] { + const parts: string[] = []; + let end = input.length; + let quoted = false; + for (let index = input.length - 1; index >= 0; index -= 1) { + const char = input.charAt(index); + if (char === `"` && quoted) { + // The start of the quoted string, unless it is escaped (by an odd + // number of backslashes before it) + let backslashes = 0; + while (input.charAt(index - backslashes - 1) === "\\") { + backslashes += 1; + } + quoted = backslashes % 2 === 1; + } else if (char === `"`) { + quoted = true; + } else if (char === separator && !quoted) { + parts.push(input.slice(index + 1, end)); + end = index; + } + } + parts.push(input.slice(0, end)); + return parts.toReversed(); +} + +/** 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; +} + +/** 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, "\\$")}"`; +} + +/** + * Whether a weight is a qvalue (`null` is the default weight): a number, + * never anything that converts to one (e.g. an object of JavaScript, whose + * `toString()` may tell another number on a later call) + */ +export function isQvalue(q_value: unknown): q_value is number | null { + return ( + q_value === null || + (typeof q_value === "number" && qvalue.test(`${q_value}`)) + ); +} + +/** Why a weight is not a qvalue (see `isQvalue()`), without converting it */ +export function qvalueRefusal(q_value: unknown): string { + return typeof q_value === "number" + ? `\`q_value\` is not a qvalue: ${q_value}` + : "`q_value` is not a number"; +} + +/** + * A value of the init of a header as a string, converted once (`String()`): + * the types declare a string, but JavaScript may give anything (e.g. an + * object whose `toString()` tells another string on a later call), so that + * the string checked is the one kept. A `Symbol` is refused (as JavaScript + * refuses to convert it implicitly), and so is what the conversion throws + * (e.g. the `TypeError` of V8 for an object without a primitive value), each + * formatted (see `formatStack()`). + */ +export function stringOf(value: unknown): string { + if (typeof value === "symbol") { + throw formatStack( + new TypeError("Cannot convert a Symbol value to a string"), + ); + } + try { + return String(value); + } catch (error: unknown) { + throw formatStack(error as Error); + } +} + +/** + * A copy of an array, read once (its `length`, then each element by its + * index, e.g. by a getter of a proxy), so that the values checked are the + * ones used; anything else is refused with a `TypeError` (`name` is not an + * array) that describes it (see `described()`), formatted (see + * `formatStack()`). An error that reading it throws (e.g. of a revoked + * proxy) is left to the boundary of the caller (see `formatting()`). + */ +export function arrayOf(value: unknown, name: string): unknown[] { + if (!Array.isArray(value)) { + throw formatStack( + new TypeError(`${name} is not an array: ${described(value)}`), + ); + } + const { length } = value; + const copy: unknown[] = []; + for (let index = 0; index < length; index += 1) { + copy.push(value[index]); + } + return copy; +} + +/** + * Checks the input of a parser of a header that takes one field value + * (`fromString()`): `undefined` or a string, anything else refused with a + * `TypeError` that describes it (see `described()`), formatted (see + * `formatStack()`), before anything reads or converts it. A string is then + * parsed without any error of V8. + */ +export function checkFieldValue(input: unknown): void { + if (typeof input !== "undefined" && typeof input !== "string") { + throw formatStack( + new TypeError(`The header is not a string: ${described(input)}`), + ); + } +} + +/** + * The input of a parser of a header that takes its field lines too + * (`fromString()`, `parse()`, and the header of `negotiate()`): `undefined`, + * a string, or an array of strings, copied once (see `arrayOf()`). Anything + * else (also an element that is not a string) is refused with a `TypeError` + * that describes it (see `described()`), formatted (see `formatStack()`), + * before anything converts it. + */ +export function fieldLinesOf(input: unknown): string[] | string | undefined { + if (typeof input === "undefined" || typeof input === "string") { + return input; + } + const lines = arrayOf(input, "The header"); + for (const line of lines) { + if (typeof line !== "string") { + throw formatStack( + new TypeError(`A field line is not a string: ${described(line)}`), + ); + } + } + return lines as string[]; +} + +/** + * The offers of a content negotiation (`negotiate()`), copied once (see + * `arrayOf()`), each checked by `valid` (which a string must pass): anything + * else (e.g. `null`, a number, or an object, whatever its `toString()` + * tells) is refused with a `TypeError` (`refusal` and the offer, described + * without converting it, see `described()`), formatted (see `formatStack()`) + */ +export function offersOf( + offers: unknown, + refusal: string, + valid: (offer: string) => boolean, +): string[] { + const copy = arrayOf(offers, "`offers`"); + for (const offer of copy) { + if (typeof offer !== "string" || !valid(offer)) { + throw formatStack(new TypeError(`${refusal}: ${described(offer)}`)); + } + } + return copy as string[]; +} + +/** `Date.prototype.getTime()` of the engine */ +const getTime: unknown = Reflect.get(Date.prototype, "getTime"); + +/** + * The time of a `Date` (also of a subclass), which the engine reads from the + * date itself, never by its methods nor by the traps of a proxy (a proxy of + * a date has no time), or `null` for anything else + */ +export function timeOf(date: unknown): number | null { + try { + return Reflect.apply(getTime as () => number, date, []); + } catch { + return null; + } +} + +/** 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; +} + +/** 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; +} + +/** + * `value` without the spaces and tabs (OWS) around it, in linear time (a regular + * expression such as `[ \t]+$` backtracks polynomially on long runs of them) + */ +export function trimOws(value: string): string { + let start = 0; + let end = value.length; + while (start < end && (value[start] === " " || value[start] === "\t")) { + start += 1; + } + while (end > start && (value[end - 1] === " " || value[end - 1] === "\t")) { + end -= 1; + } + return value.slice(start, end); +} diff --git a/packages/binden/src/headers/range.ts b/packages/binden/src/headers/range.ts new file mode 100644 index 00000000..ea559e89 --- /dev/null +++ b/packages/binden/src/headers/range.ts @@ -0,0 +1,138 @@ +import { checkFieldValue, trimOws } from "./list.js"; +import { formatting, objectOf } from "../formatting.js"; +import { formatStack } from "../exchange.js"; + +/** 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; +} + +/** 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; + 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`): anything + * else throws a `TypeError`, and so does an init that is not an object. What + * reading it throws (e.g. a revoked proxy) is formatted (see + * `formatting()`). + */ + public constructor(init: IRange) { + // Each value is read once (e.g. by a getter): the values checked are the + // ones kept + const checked = formatting(() => + Range.#read(objectOf(init, "init") as IRange), + ); + for (const [name, value] of [ + ["start", checked.start], + ["end", checked.end], + ] as const) { + if (value !== null && !(Number.isSafeInteger(value) && value >= 0)) { + throw formatStack( + new TypeError(`\`${name}\` is not a non-negative integer`), + ); + } + } + // Only an unsatisfied `ContentRange` has neither + if ( + new.target === Range && + checked.start === null && + checked.end === null + ) { + throw formatStack(new TypeError("A range has a `start` or an `end`")); + } else if ( + checked.start !== null && + checked.end !== null && + checked.end < checked.start + ) { + throw formatStack(new TypeError("`end` is less than `start`")); + } + this.#unit = "bytes"; + this.#start = checked.start; + this.#end = checked.end; + } + + /** The values of an init, each read once */ + static #read({ start = null, end = null }: IRange): { + readonly start: number | null; + readonly end: number | null; + } { + return { start, 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 ?? ""}`; + } + + /** + * 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. An input that is neither a string nor + * `undefined` throws a `TypeError`. + */ + public static fromString(input?: string): Range[] { + checkFieldValue(input); + // The unit is case-insensitive (RFC 9110, section 14.1) + const ranges = + /^[ \t]*bytes=(?.*)$/iu.exec(input ?? "")?.groups?.ranges ?? ""; + const output: Range[] = []; + + for (const element of ranges.split(",")) { + // Trimmed first: `[ \t]*` on both sides of an empty `\d*` would backtrack + // polynomially on long runs of tabs + const { first = "", last = "" } = + /^(?\d*)[ \t]*-[ \t]*(?\d*)$/u.exec(trimOws(element)) + ?.groups ?? {}; + const start = first === "" ? null : Number(first); + const end = last === "" ? null : Number(last); + const valid = + start === null + ? end !== null + : Number.isSafeInteger(start) && (end === null || start <= end); + + if (valid && (end === null || Number.isSafeInteger(end))) { + output.push(new Range({ start, end })); + } + } + + return output; + } +} + +export default Range; diff --git a/packages/binden/src/listeners.ts b/packages/binden/src/listeners.ts new file mode 100644 index 00000000..646eeb3f --- /dev/null +++ b/packages/binden/src/listeners.ts @@ -0,0 +1,380 @@ +import { EventEmitter } from "node:events"; +import { owners } from "./seal.js"; + +/** A listener of an event, or a callback (e.g. of `write()`) */ +export type IListener = (...args: unknown[]) => unknown; + +/** + * Who has added a bound listener: the work of an application to a response + * (`user`, which an abandoned work sees closed early, see `kRevoke`), a work + * to a source piped into a response (`source`), or a callback (e.g. of + * `write()`). The observers of the framework are not listeners (see + * `kListen`). + */ +export type IKind = "callback" | "source" | "user"; + +/** A listener bound to the work that has added it (see `bind()`) */ +export interface IBinding { + /** The work that has added it (`undefined` for a work of no request) */ + readonly owner: object | undefined; + readonly kind: IKind; + /** The listener as it has been added */ + readonly listener: IListener; + /** Whether it runs once (its wrapper is the one that `rawListeners()` has) */ + readonly once: boolean; +} + +/** Removes a listener that has run once (see `bind()`) */ +export interface IOnce { + readonly emitter: EventEmitter; + readonly event: string | symbol; +} + +/** Runs a call of a bound listener (e.g. to catch what it throws) */ +export type IAround = (call: () => unknown) => unknown; + +const bindings = new WeakMap(); + +/** The objects that the framework mutates now, with its rights (see `force()`) */ +interface IFrame { + readonly target: object; + readonly parent: IFrame | null; +} + +let frame: IFrame | null = null; + +/** Runs a function with the rights of the framework on `target` */ +export function force(target: object, fn: () => T): T { + const previous = frame; + frame = { target, parent: previous }; + try { + return fn(); + } finally { + frame = previous; + } +} + +/** Whether the framework mutates `target` now, with its rights (see `force()`) */ +export function forced(target: object): boolean { + for (let current = frame; current !== null; current = current.parent) { + if (current.target === target) { + return true; + } + } + return false; +} + +/** + * Runs a function as the work of `owner` (`undefined` for a work of no + * request), without the rights of the framework on any object + */ +export function as(owner: object | undefined, fn: () => T): T { + const previous = frame; + frame = null; + try { + return typeof owner === "undefined" + ? owners.exit(fn) + : owners.run(owner, fn); + } finally { + frame = previous; + } +} + +/** + * The name of the wrappers of `once()` of Node.js (e.g. `wrapper`), which + * the wrappers of `bind()` have as well + */ +const onceName = ((): string => { + const probe = new EventEmitter(); + probe.once("probe", () => null); + return (probe.rawListeners("probe")[0] as IListener).name; +})(); + +/** A wrapper that runs with the receiver of its call (see `bind()`) */ +function wrap(run: (receiver: unknown, args: unknown[]) => unknown): IListener { + return function wrapper(this: unknown, ...args: unknown[]): unknown { + return run(this, args); + }; +} + +/** + * A wrapper that removes itself from its emitter, then runs once with the + * emitter as its receiver (see `bind()`) + */ +function wrapOnce( + run: (receiver: unknown, args: unknown[]) => unknown, + once: IOnce, +): IListener { + let fired = false; + function wrapper(...args: unknown[]): unknown { + if (fired) { + return null; + } + fired = true; + once.emitter.removeListener(once.event, wrapper); + return run(once.emitter, args); + } + Object.defineProperty(wrapper, "name", { value: onceName }); + return wrapper; +} + +/** + * A listener (or a callback) that runs as the work of `owner`, whose rights + * it keeps (and only those: never the rights of the framework, which may emit + * its event), with its arguments and the receiver that its emitter (or + * Node.js) gives it. Its `listener` is the original, which `removeListener()` + * and `listeners()` find. With `once`, it removes itself from its emitter + * before it runs once, with the emitter as its receiver (as the wrapper of + * `once()` of Node.js, whose name it has). + */ +export function bind( + listener: IListener, + owner: object | undefined, + kind: IKind, + once: IOnce | null, + around: IAround | null = null, +): IListener { + if (typeof listener !== "function") { + // Refused by Node.js as it is + return listener; + } + const run = (receiver: unknown, args: unknown[]): unknown => { + const call = (): unknown => Reflect.apply(listener, receiver, args); + return as(owner, around === null ? call : (): unknown => around(call)); + }; + const bound = once === null ? wrap(run) : wrapOnce(run, once); + Object.assign(bound, { listener }); + bindings.set(bound, { owner, kind, listener, once: once !== null }); + return bound; +} + +/** How a listener has been bound (`undefined` for one that has not been) */ +export function bindingOf(raw: unknown): IBinding | undefined { + return bindings.get(raw as IListener); +} + +/** + * What a work does when a listener that it has added to a piped source in + * its window (see `openWindow()`) throws at its `end` (e.g. the `end()` of + * `pipeline()` into a head that Node.js refuses): `true` once its pipe has + * failed (see `onPipeFailure()`) + */ +export type IPipeFailureHandler = ( + error: unknown, + source: EventEmitter, +) => boolean; + +/** What issues the tokens of the works (an exchange, see `Exchange.token()`) */ +export interface ITokenIssuer { + /** Whether it has issued a token */ + issued(token: object): boolean; +} + +/** + * What a late failure of a pipe of a work does (see `IFailureRoute`): it is + * reported (e.g. logged at `debug`), by a report that does not hold the + * request (its exchange, response or context) + */ +export interface ILateFailures { + report: ((error: unknown) => void) | null; +} + +/** + * The route of the pipe failures of a work (see `onPipeFailure()`): its + * handler while the exchange that has issued its token runs, which the + * exchange clears (`null`) once it has finished, so that what still holds + * the token or a listener of the work (e.g. a timer created in its context, + * or a listener left on a shared source) no longer holds the request. A + * failure through a cleared route is late: it is reported, never thrown. + */ +export interface IFailureRoute { + fail: IPipeFailureHandler | null; + readonly late: ILateFailures; +} + +/** + * The failure routes of the works, by their token: set once by the + * exchange that has issued the token (with the response of that exchange), + * never replaced (e.g. by a pipe of the work into the response of another + * request, which does not know it) + */ +const failures = new WeakMap(); + +/** + * Sets what a pipe failure of a work does (see `IPipeFailureHandler`), and + * what it does once the exchange has finished (`late`): once, by the + * exchange that has issued its token, which gets the route to clear it (see + * `IFailureRoute`). `null` otherwise (nothing is set). + */ +export function onPipeFailure( + token: object, + issuer: ITokenIssuer, + fail: IPipeFailureHandler, + late: ILateFailures, +): IFailureRoute | null { + if (failures.has(token) || !issuer.issued(token)) { + return null; + } + const route: IFailureRoute = { fail, late }; + failures.set(token, route); + return route; +} + +/** The failure route of a work (`null` without one, e.g. for a work of no request) */ +function routeOf(owner: object | undefined): IFailureRoute | null { + return typeof owner === "undefined" ? null : (failures.get(owner) ?? null); +} + +/** + * What a listener of `end` of a work does with what it throws (see + * `patch()`): fails the pipe of the work by its route (`true` once handled, + * thrown otherwise), or, once the route is cleared, reports it as late + */ +function failed( + route: IFailureRoute, + error: unknown, + source: EventEmitter, +): boolean { + const { fail } = route; + if (fail === null) { + route.late.report?.(error); + return true; + } + return fail(error, source); +} + +/** + * The listener-adding methods of a source, patched while its window is open + * (see `openWindow()`): by the method of its class that each calls, and + * whether it adds a listener that runs once + */ +const adders = [ + ["on", "on", false], + ["addListener", "addListener", false], + ["once", "on", true], + ["prependListener", "prependListener", false], + ["prependOnceListener", "prependListener", true], +] as const; + +/** The names of the listener-adding methods of a source (see `adders`) */ +export const adderNames: readonly string[] = adders.map(([name]) => name); + +/** The patches of a source whose window is open, and how many windows share them */ +interface IWindow { + count: number; + readonly patches: ReadonlyMap; +} + +const windows = new WeakMap(); + +/** + * Whether a method of a source is the patch of its open window (see + * `openWindow()`): not a method that has been set on the source since + */ +export function isPatch(source: object, name: string): boolean { + const patched = windows.get(source)?.patches.get(name); + return ( + typeof patched !== "undefined" && + Object.getOwnPropertyDescriptor(source, name)?.value === patched + ); +} + +/** + * A patched listener-adding method of a source: it binds every listener to + * the work that adds it, then adds it by the method of its class. What a + * listener of `end` throws fails the pipe of that work, by the failure + * route of the work (see `failures`), whatever response has opened the + * window and whatever work emits the event: the listener holds the route, + * never the request itself. + */ +function patch(inherited: string, once: boolean): IListener { + return function patched( + this: EventEmitter, + event: unknown, + listener: unknown, + ): unknown { + const owner = owners.getStore(); + const route = event === "end" ? routeOf(owner) : null; + const around: IAround | null = + route === null + ? null + : (call): unknown => { + try { + return call(); + } catch (error: unknown) { + if (failed(route, error, this)) { + return null; + } + throw error; + } + }; + const bound = bind( + listener as IListener, + owner, + "source", + once ? { emitter: this, event: event as string | symbol } : null, + around, + ); + const method = Reflect.get( + Object.getPrototypeOf(this) as object, + inherited, + ) as IListener; + return Reflect.apply(method, this, [event, bound]); + }; +} + +/** Removes the patches of a source that are still its own methods */ +function restore( + source: object, + patches: ReadonlyMap, +): void { + for (const [name, value] of patches) { + if (Object.getOwnPropertyDescriptor(source, name)?.value === value) { + Reflect.deleteProperty(source, name); + } + } +} + +/** Closes a window of a source (see `openWindow()`) */ +function closeWindow(source: EventEmitter, window: IWindow): void { + window.count -= 1; + if (window.count === 0) { + windows.delete(source); + restore(source, window.patches); + } +} + +/** + * Opens the window of a source that is piped now: until the end of the + * current turn (a `process.nextTick()` queued now), its listener-adding + * methods bind every listener added to it to the work that adds it (e.g. the + * `end` of `pipeline()`, or a listener of another work). Patched on the + * source itself, never on a prototype; the windows of one source share the + * patches, removed by the last one (unless replaced meanwhile). `false` when + * the source cannot be patched (e.g. it is not extensible). + */ +export function openWindow(source: EventEmitter): boolean { + let window = windows.get(source); + if (typeof window === "undefined") { + const patches = new Map(); + for (const [name, inherited, once] of adders) { + const value = patch(inherited, once); + if ( + !Reflect.defineProperty(source, name, { + configurable: true, + writable: true, + value, + }) + ) { + restore(source, patches); + return false; + } + patches.set(name, value); + } + window = { count: 0, patches }; + windows.set(source, window); + } + window.count += 1; + process.nextTick(closeWindow, source, window); + return true; +} diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts new file mode 100644 index 00000000..d95620d0 --- /dev/null +++ b/packages/binden/src/logger.ts @@ -0,0 +1,497 @@ +import { BindenAbortError, formatStack } from "./exchange.js"; +import { + type ILogger, + Logger, + serializers as defaultSerializers, +} from "@binden/logger"; +import { formatting, objectOf } from "./formatting.js"; +import { BindenError } from "./error.js"; +import { constants } from "node:os"; +import { isContext } from "./brands.js"; +import { isStatus } from "./status.js"; +import { property } from "./property.js"; + +/** Where the framework has met an error */ +export type ILocation = + "cleanup" | "error_handler" | "middleware" | "send" | "shutdown"; + +/** 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"], + [ReferenceError.prototype, "ReferenceError"], + [SyntaxError.prototype, "SyntaxError"], + [TypeError.prototype, "TypeError"], + [URIError.prototype, "URIError"], + [Error.prototype, "Error"], +]); + +/** + * 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", + "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", + "aborted", + "deadline", +]); + +/** The name of the nearest known error class of a value (never one it defines itself) */ +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; +} + +/** 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; + /** 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; +} + +/** + * 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` + */ +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) }), + }; +} + +/** + * A level of the records of a logger (see `ILogger`): a method of it that + * `record()` calls + * + * @internal + */ +export type ILogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; + +const levels: readonly ILogLevel[] = [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", +]; + +/** + * Whether a value has the methods of a logger. What reading them throws (e.g. + * a getter of the application, or a revoked proxy) means `false`, unless + * `strict`: then it is thrown as it is (to the boundary of the caller, see + * `formatting()`), as when an option of the application is checked. + */ +export function isLogger(value: unknown, strict = false): value is ILogger { + return ( + typeof value === "object" && + value !== null && + [...levels, "child" as const].every( + (method) => + typeof (strict + ? (Reflect.get(value, method) as unknown) + : property(value, method)) === "function", + ) + ); +} + +/** Does nothing (e.g. with a rejection that is observed) */ +function ignore(): void { + // Observed +} + +/** + * The private paths of the loggers of binden (see `SafeLogger`), set by the + * static block of the class: functions of this module, never properties of + * the class, which the application reaches (the `constructor` of a logger) + * and may replace + */ +interface IPaths { + /** Whether a value is a logger of binden, by its private brand */ + isSafe(value: unknown): value is ILogger; + /** Writes a record through a logger (see `record()`) */ + record( + logger: unknown, + level: ILogLevel, + message: string, + extra?: Record, + ): void; + /** A child of a logger of binden (see `childLogger()`) */ + child(logger: ILogger, bindings: Record): ILogger; +} + +// eslint-disable-next-line init-declarations -- set by the static block of `SafeLogger` +let paths: IPaths; + +/** + * A logger whose calls never fail: a method (or `child()`) that throws is + * ignored, and a rejected promise that it returns is observed. Its `child()` + * always gives a new one: over a child that is a logger, or over the logger + * itself when it fails (or does not return a logger, e.g. a promise). Its + * methods are its own, bound to it: none depends on its receiver, so that an + * object derived from it (e.g. with `Object.create()`) or a proxy of it logs + * through its logger. Over a logger of binden, it takes the logger of that + * one (see the constructor): one level deep at most. binden writes its own + * records by their private paths (see `record()`), never through these + * methods, which the application may replace. + */ +class SafeLogger implements ILogger { + readonly #logger: ILogger; + + /** Logs at `fatal` */ + public readonly fatal = ( + message: string, + extra?: Record, + ): void => { + this.#call("fatal", message, extra); + }; + + /** Logs at `error` */ + public readonly error = ( + message: string, + extra?: Record, + ): void => { + this.#call("error", message, extra); + }; + + /** Logs at `warn` */ + public readonly warn = ( + message: string, + extra?: Record, + ): void => { + this.#call("warn", message, extra); + }; + + /** Logs at `info` */ + public readonly info = ( + message: string, + extra?: Record, + ): void => { + this.#call("info", message, extra); + }; + + /** Logs at `debug` */ + public readonly debug = ( + message: string, + extra?: Record, + ): void => { + this.#call("debug", message, extra); + }; + + /** Logs at `trace` */ + public readonly trace = ( + message: string, + extra?: Record, + ): void => { + this.#call("trace", message, extra); + }; + + /** A child of the logger (the logger itself when it fails) */ + public readonly child = (bindings: Record): ILogger => + this.#child(bindings); + + public constructor(logger: ILogger) { + // Flat: over a logger of binden, its logger, to which it would delegate + // anyway, so that none is ever nested deeper than one level (whatever + // a `child()` of the application gives, e.g. an earlier `context.log`), + // and none keeps the loggers of binden made before it + this.#logger = SafeLogger.#isSafe(logger) ? logger.#logger : logger; + } + + static { + paths = { + isSafe: (value): value is SafeLogger => SafeLogger.#isSafe(value), + record: (logger, level, message, extra): void => { + SafeLogger.#send(logger, level, message, extra); + }, + child: (logger, bindings): ILogger => + (SafeLogger.#isSafe(logger) ? logger : new SafeLogger(logger)).#child( + bindings, + ), + }; + } + + /** + * Whether a value is a logger that this class has made, by its private + * brand: no trap of a proxy runs, and an object derived from one (or a + * proxy of one) is not + */ + static #isSafe(value: unknown): value is SafeLogger { + return typeof value === "object" && value !== null && #logger in value; + } + + /** + * Writes a record through a logger: a logger of binden by its private path + * (see `#call()`), never by its methods, which the application may have + * replaced, and anything else by its method, whose failure (also what + * reading it throws) is ignored, and whose rejected promise is observed + */ + static #send( + logger: unknown, + level: ILogLevel, + message: string, + extra?: Record, + ): void { + if (SafeLogger.#isSafe(logger)) { + logger.#call(level, message, extra); + return; + } + try { + const target = logger as ILogger; + const result: unknown = + typeof extra === "undefined" + ? target[level](message) + : target[level](message, extra); + if (typeof property(result, "then") === "function") { + Promise.resolve(result).catch(ignore); + } + } catch { + // Nothing else to report it to + } + } + + #call( + level: ILogLevel, + message: string, + extra?: Record, + ): void { + SafeLogger.#send(this.#logger, level, message, extra); + } + + /** + * A new logger of this class: over the child that its logger (never one + * of binden, see the constructor) gives, or over that logger itself when + * it fails (or does not give a logger, e.g. a promise). Never one that the + * application holds (whose methods it may have replaced), nor this one. + */ + #child(bindings: Record): SafeLogger { + const logger = this.#logger; + try { + const child: unknown = logger.child(bindings); + if (typeof property(child, "then") === "function") { + // Not a logger (e.g. of an `async` function): its rejection is + // observed, and the logger itself is used + Promise.resolve(child).catch(ignore); + } else if (SafeLogger.#isSafe(child) || isLogger(child)) { + return new SafeLogger(child); + } + } catch { + // The logger itself + } + return new SafeLogger(logger); + } +} + +/** + * A logger whose calls never fail (see `SafeLogger`): one that binden has + * made is used as it is, anything else (also an object derived from one) is + * wrapped + */ +export function safe(logger: ILogger): ILogger { + return paths.isSafe(logger) ? logger : new SafeLogger(logger); +} + +/** + * Writes a record of binden itself through a logger (e.g. `context.log`), + * never throwing: a logger of binden by its private path (the method of its + * logger, called as its own methods call it), never through its methods, + * which the application may replace, and anything else (e.g. what the `log` + * of a subclass of `Context` gives) through its method, guarded in the same + * way (see `SafeLogger`). A `level` that is not one of `ILogLevel` drops the + * record. For the packages of binden only. + * + * @internal + */ +export function record( + logger: unknown, + level: ILogLevel, + message: string, + extra?: Record, +): void { + // Another level (e.g. a value of a package that is not one) drops the + // record: it is never converted + if (levels.includes(level)) { + paths.record(logger, level, message, extra); + } +} + +/** + * A child of a logger (see `SafeLogger.child()`) by the private path of a + * logger of binden, never its `child()`, which the application may replace + * (anything else, e.g. what a context that only looks like one gives, is + * wrapped first): never throws, and always gives a new logger of binden. For + * the packages of binden only (e.g. the logger of a parser, a child of + * `context.log`), which write their records through it with `record()`. + * + * @internal + */ +export function childLogger( + logger: ILogger, + bindings: Record, +): ILogger { + return paths.child(logger, bindings); +} + +/** + * Writes a record of binden itself through the logger of a context (see + * `record()`): what getting it throws (e.g. a `log` getter of a subclass of + * `Context`, or of the context itself) drops the record, never out of the + * caller. For the packages of binden only. + * + * @internal + */ +export function logTo( + context: { readonly log: unknown }, + level: ILogLevel, + message: string, + extra?: Record, +): void { + try { + const { log } = context; + record(log, level, message, extra); + } catch { + // Dropped + } +} + +/** + * The serializers of binden (see `createSerializers()`): a type alias, so + * that they can be given as they are to the `serializers` of a logger + */ +export type IBindenSerializers = Record< + "context" | "err" | "error" | "request" | "response", + (value: unknown) => Record +> & + Record<"body", (body?: unknown) => string>; + +/** + * The serializers of a logger of binden: those of `@binden/logger` (an + * allowlist of the details of a request and a response), an `error` (and + * `err`, the key of `pino`) that names the errors of binden, a redacted + * `body` and a `context` without the fields that the application sets on it. + * With `error_details`, an `error` adds its message, its stack, its cause and + * any `code`, which may quote the input of a request (as `log_error_details` + * does for the framework logs). Options that are not an object throw a + * `TypeError`, and what reading them throws (e.g. a revoked proxy) is + * formatted (see `formatting()`). + */ +export function createSerializers(options?: { + readonly error_details?: boolean | undefined; +}): IBindenSerializers { + const error_details = formatting(() => { + const { error_details: details = false } = objectOf( + options, + "options", + true, + ) as { readonly error_details?: unknown }; + return details; + }); + if (typeof error_details !== "boolean") { + throw formatStack(new TypeError("`error_details` is not a boolean")); + } + const error = (value: unknown): Record => { + const logged = snapshot(value, { + location: "middleware", + details: error_details, + }); + // Where the framework met it: not known here + delete logged.location; + return logged; + }; + return { + ...defaultSerializers, + error, + err: error, + body: (): string => "[Redacted]", + context: (input: unknown): Record => { + if (!isContext(input)) { + return { type: typeOf(input) }; + } + // What its getters throw (e.g. of a subclass) is formatted + return formatting(() => ({ + id: input.id, + request: defaultSerializers.request(input.request), + response: defaultSerializers.response(input.response), + })); + }, + }; +} + +/** The serializers of the default logger of binden (without the details of the errors) */ +export const serializers: IBindenSerializers = createSerializers(); + +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/src/middleware.ts b/packages/binden/src/middleware.ts new file mode 100644 index 00000000..eaa52bd4 --- /dev/null +++ b/packages/binden/src/middleware.ts @@ -0,0 +1,80 @@ +import type { Context, IParams } from "./context.js"; +import { formatting, objectOf } from "./formatting.js"; +import { formatStack } from "./exchange.js"; + +/** 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; + +/** 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 { + /** Whether the middleware is skipped (`false`) */ + disabled?: boolean | undefined; + /** Whether the errors of the middleware are ignored (`false`) */ + ignore_errors?: boolean | undefined; +} + +/** The options of a middleware, each read once, with their defaults */ +function read({ + disabled = false, + ignore_errors = false, +}: IMiddlewareParams): IMiddlewareParams { + return { disabled, ignore_errors }; +} + +/** A middleware as a class: its `run()` handles the context */ +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`, and so do options that are not an object. What reading them + * throws (e.g. a revoked proxy) is formatted (see `formatting()`). + */ + public constructor(options?: IMiddlewareParams) { + // Each option is read once (e.g. by a getter) + const checked = formatting(() => + read(objectOf(options, "options", true) as IMiddlewareParams), + ); + if (typeof checked.disabled !== "boolean") { + throw formatStack(new TypeError("`disabled` is not a boolean")); + } else if (typeof checked.ignore_errors !== "boolean") { + throw formatStack(new TypeError("`ignore_errors` is not a boolean")); + } + this.disabled = checked.disabled; + this.ignore_errors = checked.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; +} + +/** 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/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/property.ts b/packages/binden/src/property.ts new file mode 100644 index 00000000..3fe2f00f --- /dev/null +++ b/packages/binden/src/property.ts @@ -0,0 +1,22 @@ +/** + * 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; + } +} + +/** + * 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/src/proxy.ts b/packages/binden/src/proxy.ts new file mode 100644 index 00000000..89d43970 --- /dev/null +++ b/packages/binden/src/proxy.ts @@ -0,0 +1,311 @@ +import { SocketAddress, isIPv4, isIPv6 } from "node:net"; +import Forwarded from "./headers/forwarded.js"; +import type { IncomingHttpHeaders } from "node:http"; +import { formatStack } from "./exchange.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 + | { + /** 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; +} + +/** 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 formatStack(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 formatStack( + new RangeError("`trust_proxy` is not a non-negative integer"), + ); + } else if (typeof trust !== "function") { + throw formatStack( + 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 formatStack( + 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` (its field lines, each read from its end) as + * hops, `null` for an invalid one + */ +function forwardedHops(lines?: readonly string[]): (IHop | null)[] { + return Forwarded.parse(lines).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(distinct.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/redirect.ts b/packages/binden/src/redirect.ts new file mode 100644 index 00000000..aaafe9f4 --- /dev/null +++ b/packages/binden/src/redirect.ts @@ -0,0 +1,100 @@ +import { formatStack } from "./exchange.js"; +/** 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`; an invalid URL, 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 formatStack( + new TypeError("The location is not a non-empty string or a URL"), + ); + } else if (forbidden.test(input)) { + throw formatStack( + 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, "")); + // Checked first: the error of `new URL()` (`ERR_INVALID_URL`, which + // Node.js creates while the frames of the response are on the stack) would + // not be formatted (see `formatStack()`) + if (!URL.canParse(input, base)) { + throw formatStack(new TypeError("The location is not a valid URL")); + } + const url = new URL(input, base); + + if (userinfo) { + throw formatStack(new TypeError("The location has userinfo")); + } else if (url.protocol !== "http:" && url.protocol !== "https:") { + throw formatStack( + new TypeError(`The location has the scheme ${url.protocol}`), + ); + } else if ( + !allow_external && + (network || (absolute && url.origin !== origin)) + ) { + throw formatStack( + 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/request.ts b/packages/binden/src/request.ts new file mode 100644 index 00000000..4f21d705 --- /dev/null +++ b/packages/binden/src/request.ts @@ -0,0 +1,788 @@ +import { + type IProxyResult, + type ITrustProxy, + checkTrustProxy, + isAuthority, + resolveProxy, + strictBoolean, +} from "./proxy.js"; +import { type ParsedUrlQuery, parse } from "node:querystring"; +import { described, formatting } from "./formatting.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 { BindenError } from "./error.js"; +import ContentEncoding from "./headers/content-encoding.js"; +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 { IncomingMessage } from "node:http"; +import Range from "./headers/range.js"; +import type { Socket } from "node:net"; +import { TLSSocket } from "node:tls"; +import { brandRequests } from "./brands.js"; +import { formatStack } from "./exchange.js"; +import { randomUUID } from "node:crypto"; +import { token } from "./headers/list.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:"; + +/** + * 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; + /** + * 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 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 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: { + /** The greatest number of keys (a positive integer, or `Infinity`) */ + 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 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; +} + +/** 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 { + /** + * 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?: + | { + /** 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) */ +function optionalObject(value: unknown, name: string): Record { + if (typeof value === "undefined") { + return {}; + } else if (typeof value !== "object" || value === null) { + throw formatStack(new TypeError(`\`${name}\` is not an object`)); + } + 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 formatStack( + new TypeError( + `\`allowed_hosts\` has an invalid host: ${described(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 formatStack( + new TypeError("`allowed_hosts` is not an array or a function"), + ); + } else if (value.length === 0) { + throw formatStack( + 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 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 formatStack(new TypeError("`request_id.header` is not a field name")); + } else if (typeof trust_incoming !== "boolean") { + throw formatStack( + new TypeError("`request_id.trust_incoming` is not a boolean"), + ); + } else if (typeof echo !== "boolean") { + throw formatStack(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 (see `validateRequestOptions()`) */ +function checkRequestOptions(input?: IRequestOptionsInput): IRequestOptions { + const { + trust_proxy = false, + encoded_slashes = false, + allowed_hosts, + request_id, + query, + } = optionalObject(input, "options"); + const { max_keys = 1000 } = optionalObject(query, "query"); + if (typeof encoded_slashes !== "boolean") { + throw formatStack(new TypeError("`encoded_slashes` is not a boolean")); + } else if (typeof max_keys !== "number") { + throw formatStack(new TypeError("`query.max_keys` is not a number")); + } else if ( + max_keys !== Number.POSITIVE_INFINITY && + !(Number.isSafeInteger(max_keys) && max_keys > 0) + ) { + throw formatStack( + new RangeError("`query.max_keys` is not a positive integer"), + ); + } + return Object.freeze({ + trust_proxy: checkTrustProxy(trust_proxy), + encoded_slashes, + allowed_hosts: checkAllowedHosts(allowed_hosts), + request_id: checkRequestId(request_id), + query: Object.freeze({ max_keys }), + }); +} + +/** + * The request options with their defaults, checked: a `TypeError` for a value + * 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. + * What reading them throws (e.g. a revoked proxy) is formatted (see + * `formatting()`). + */ +export function validateRequestOptions( + input?: IRequestOptionsInput, +): IRequestOptions { + return formatting(() => checkRequestOptions(input)); +} + +/** 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; + #body?: unknown; + #content_encoding?: readonly ContentEncoding[] | null; + #content_type?: ContentType | null; + #cookies?: readonly Cookie[]; + #forwarded?: readonly Forwarded[]; + #id?: string; + #if_modified_since?: IfModifiedSince | null; + #proxy?: IProxyResult; + #range?: readonly Range[]; + #query?: ParsedUrlQuery; + + static { + // A request made by the constructor (see `isRequest()`) + brandRequests((value) => #id in value); + } + + /** + * A request of a connection, as `IncomingMessage` creates it: what reading + * the socket throws (e.g. a revoked proxy) is formatted (see + * `formatStack()`) + */ + public constructor(socket: Socket) { + super(BindenRequest.#socket(socket)); + } + + /** + * 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 { + try { + return Accept.negotiate(types, this.headers.accept); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * The preferred one of the offered charsets, by the `Accept-Charset` header + * (see `AcceptCharset.negotiate()`), or `false` + */ + public acceptsCharsets(...charsets: string[]): string | false { + try { + return AcceptCharset.negotiate(charsets, this.headers["accept-charset"]); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * The preferred one of the offered content codings, by the + * `Accept-Encoding` header (see `AcceptEncoding.negotiate()`), or `false` + */ + public acceptsEncodings(...encodings: string[]): string | false { + try { + return AcceptEncoding.negotiate( + encodings, + this.headers["accept-encoding"], + ); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * The preferred one of the offered language tags, by the `Accept-Language` + * header (see `AcceptLanguage.negotiate()`), or `false` + */ + public acceptsLanguages(...languages: string[]): string | false { + try { + return AcceptLanguage.negotiate( + languages, + this.headers["accept-language"], + ); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The known codings of `Accept-Encoding`, from the greatest weight */ + public get accept_encoding(): AcceptEncoding[] { + try { + if (!this.#accept_encoding) { + const ae = this.headers["accept-encoding"]; + this.#accept_encoding = AcceptEncoding.fromString(ae); + } + return [...this.#accept_encoding]; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The `Authorization` header, or `null` (unsupported or missing) */ + public get authorization(): Authorization | null { + try { + if (typeof this.#authorization === "undefined") { + const { authorization } = this.headers; + this.#authorization = Authorization.fromString(authorization); + } + return this.#authorization; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The parsed body (set once, e.g. by a body parser) */ + public get body(): unknown { + 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; + } + } + + /** The content codings of the body, or `null` when one of them is unknown */ + public get content_encoding(): ContentEncoding[] | null { + try { + if (typeof this.#content_encoding === "undefined") { + this.#content_encoding = ContentEncoding.fromString( + this.headers["content-encoding"], + ); + } + return this.#content_encoding === null + ? null + : [...this.#content_encoding]; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The `Content-Type` header, or `null` (invalid or missing) */ + public get content_type(): ContentType | null { + try { + if (typeof this.#content_type === "undefined") { + this.#content_type = ContentType.fromString( + this.headers["content-type"], + ); + } + return this.#content_type; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The valid cookies of the `Cookie` header */ + public get cookies(): Cookie[] { + try { + if (!this.#cookies) { + this.#cookies = Cookie.fromString(this.headers.cookie); + } + return [...this.#cookies]; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * The valid elements of `Forwarded`, from any client (see `ip` and + * `protocol` for those of the trusted proxies) + */ + public get forwarded(): Forwarded[] { + try { + if (!this.#forwarded) { + this.#forwarded = Forwarded.fromString(this.headers.forwarded); + } + return [...this.#forwarded]; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * A header by its name, in any case: a name that is not a string throws a + * `TypeError`, which describes it without converting it (see `described()`) + */ + public header(name: string): string[] | string | undefined { + try { + if (typeof name !== "string") { + throw formatStack( + new TypeError(`The name is not a string: ${described(name)}`), + ); + } + return this.headers[name.toLowerCase()]; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * 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 { + try { + this.#id ??= this.#incomingId() ?? randomUUID(); + return this.#id; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The `If-Modified-Since` header, or `null` (not an HTTP-date, or missing) */ + public get if_modified_since(): IfModifiedSince | null { + try { + if (typeof this.#if_modified_since === "undefined") { + const input = this.headers["if-modified-since"]; + this.#if_modified_since = IfModifiedSince.fromString(input); + } + return this.#if_modified_since; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * 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 { + try { + const { authority } = BindenRequest.#split(this.url ?? "/"); + const origin = this.#origin(authority); + return origin === null ? null : new URL(origin).host; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * 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 { + try { + return this.#resolve().ip; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The address of the client (unless unknown) and of the trusted proxies, left to right */ + public get ips(): readonly string[] { + try { + return [...this.#resolve().ips]; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * 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 { + try { + const { protocol } = this.#resolve(); + if (protocol === null) { + return this.socket instanceof TLSSocket ? "https:" : "http:"; + } + return `${protocol}:`; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * 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()`. + */ + // eslint-disable-next-line class-methods-use-this -- overridden by subclasses + protected get options(): Readonly { + return defaults; + } + + /** + * 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 { + try { + if (!this.#query) { + 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 + return Object.fromEntries( + Object.entries(this.#query).map(([key, value]) => [ + key, + Array.isArray(value) ? [...value] : value, + ]), + ); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** The ranges of the `Range` header */ + public get range(): Range[] { + try { + if (typeof this.#range === "undefined") { + this.#range = Range.fromString(this.headers.range); + } + return [...this.#range]; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** Whether the protocol is `https:` (see `protocol`) */ + public get secure(): boolean { + try { + return this.protocol === "https:"; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * 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 { + try { + const target = this.url ?? "/"; + const { authority, rest } = BindenRequest.#split(target); + const { path, query } = + target === "*" && this.method === "OPTIONS" + ? { path: "*", query: "" } + : BindenRequest.#target(rest, this.options.encoded_slashes); + const origin = this.#origin(authority); + if (origin === null) { + throw new BindenError(400); + } + 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; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * The socket of a new request, read first as `IncomingMessage` reads it + * (its `readableHighWaterMark`, when it is truthy), so that what reading it + * throws is formatted (see `formatStack()`) before Node.js reads it again + */ + static #socket(socket: Socket): Socket { + const value: unknown = socket; + try { + // A primitive has no getter of its own + if ( + (typeof value === "object" && value !== null) || + typeof value === "function" + ) { + Reflect.get(value, "readableHighWaterMark"); + } + return socket; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** 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( + this.socket.remoteAddress, + checkTrustProxy(this.options.trust_proxy), + this.headers, + this.headersDistinct, + ); + return this.#proxy; + } + + /** + * 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), 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); + } + 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; + } + + /** + * 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 } { + // The rest is sliced off rather than matched: `.*$` after the authority + // would backtrack quadratically on a line terminator (e.g. of a + // rewritten `request.url`), which `.` does not match + const absolute = /^(?[a-z][\d+.a-z-]*):\/\/(?[^/?]*)/iu.exec( + target, + ); + if (!absolute) { + return { authority: null, rest: target }; + } + const { scheme, host } = absolute.groups as Record< + "host" | "scheme", + string + >; + const tail = target.slice(absolute[0].length); + // 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, a space or a + * control character (which the URL parser would remove or encode after the + * checks, e.g. `.\t.` is `..`, as a rewritten `request.url` may have them), an + * invalid percent-encoding, or dot-segments (which the URL parser would + * remove, so 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, + encoded_slashes: boolean, + ): { path: string; query: string } { + if ( + !rest.startsWith("/") || + /[#\\]/u.test(rest) || + // eslint-disable-next-line no-control-regex -- control characters on purpose + /[\x00-\x20\x7F]/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)) || + (!encoded_slashes && /%(?:2f|5c)/iu.test(path)) + ) { + throw new BindenError(400); + } + return { path, query }; + } +} diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts new file mode 100644 index 00000000..b1a53320 --- /dev/null +++ b/packages/binden/src/response.ts @@ -0,0 +1,5403 @@ +import Cookie, { type ICookie } from "./headers/cookie.js"; +import { type Duplex, Readable } from "node:stream"; +import { EventEmitter, errorMonitor } from "node:events"; +import { Exchange, carrierOf, formatStack, readsSafely } from "./exchange.js"; +import { + type IFramingState, + framingCounters, + framingFields, + framingLength, + kRestoreFraming, + kSaveFraming, + lengthRefusal, + lengthValues, +} from "./framing.js"; +import { + type IListener, + adderNames, + as, + bind, + bindingOf, + force, + forced, + isPatch, + openWindow, +} from "./listeners.js"; +import { + type IOwnership, + type IPipeFailure, + type IRepresentation, + type IRepresentationNegotiator, + type IResponseOperation, + cookieGuards, + kCancel, + kCancelling, + kDiscardWrite, + kFramework, + kListen, + kNegotiator, + kOperation, + kOwnership, + kPipeFailure, + kRepresentation, + kRevoke, + kSeal, + kUnlisten, + kValidateHead, + owners, +} from "./seal.js"; +import { + type IRedirectOptions, + type IRedirectStatus, + isRedirectStatus, + redirectLocation, +} from "./redirect.js"; +import { + type ISendFileOptions, + preconditionsOf, + sendFileTo, + tagOf, +} from "./send-file.js"; +import { + type ISourceReport, + type IStreamLike, + type IStreamLikeLogs, + type IStreamLikeMethods, + decide, + decided, + decidedFor, + emitterMethods, + forwarder, + guard, + hasClosed, + hear, + readStreamLike, + releaseStreamLike, + reportLate, + resolved, + streamLikeOf, + uncarried, +} from "./stream-like.js"; +import { + IncomingMessage, + type OutgoingHttpHeader, + type OutgoingHttpHeaders, + OutgoingMessage, + ServerResponse, + validateHeaderName, + validateHeaderValue, +} from "node:http"; +import { contentDigests, representationDigests } from "./digests.js"; +import { described, formatting, objectOf } from "./formatting.js"; +import { + isArrayBuffer, + isDataView, + isProxy, + isUint8Array, +} from "node:util/types"; +import { split, trimOws } from "./headers/list.js"; +import { BindenRequest } from "./request.js"; +import ContentType from "./headers/content-type.js"; +import { brandResponses } from "./brands.js"; +import { createHash } from "node:crypto"; +import { format } from "node:util"; +import { isStatus } from "./status.js"; +import { parseHttpDate } from "./headers/http-date.js"; +import { property } from "./property.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 arguments of `writeHead()` as Node.js gets them from binden, each read + * once (see `kValidateHead`): the status code, converted as Node.js converts + * it, the reason phrase (`null` when none is given), and copies of the + * headers given (`undefined` when none are). For the packages of binden only. + * + * @internal + */ +export type IHead = readonly [ + statusCode: number, + reason: string | null, + headers: OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined, +]; + +/** 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, and for anything but an object. What reading + * them throws (e.g. a revoked proxy) is formatted (see `formatting()`). + */ +export function validateResponseOptions( + options: { readonly etag?: boolean | undefined } = {}, +): Readonly { + const { etag = false } = formatting(() => { + const given: unknown = options; + if (typeof given !== "object" || given === null) { + throw formatStack( + new TypeError(`\`options\` is not an object: ${described(given)}`), + ); + } + // Read once + const { etag: value } = options; + return { etag: value }; + }); + if (typeof etag !== "boolean") { + throw formatStack(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; + +/** + * 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 formatStack( + 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; + +/** A token (RFC 9110, section 5.6.2) */ +const token = "[!#$%&'*+\\-.^_`|~\\dA-Za-z]+"; + +/** A quoted string (RFC 9110, section 5.6.4) */ +const quoted = + '"(?:[\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*"'; + +/** `type/subtype` (RFC 9110, section 8.3.1), without whitespace */ +const strictType = new RegExp(`^${token}/${token}$`, "u"); + +/** + * A parameter of a media type: a token with a token or a quoted string as + * value (RFC 9110, section 5.6.6), without whitespace. Its alternatives start + * with distinct characters, so it is matched in linear time. + */ +const strictParameter = new RegExp(`^${token}=(?:${token}|${quoted})$`, "u"); + +/** + * Whether a media type has parameters that are each a token with a token or + * a quoted string as value (RFC 9110, section 8.3.1), with optional + * whitespace around its parts (an empty parameter is allowed). Split on the + * `;` outside of the quoted strings and matched part by part, in linear time: + * a single regular expression with optional whitespace on both sides of the + * separators backtracks exponentially on a long run of ` ;`. + */ +function isStrictMediaType(media_type: string): boolean { + const [type = "", ...parameters] = split(media_type, ";"); + return ( + strictType.test(trimOws(type)) && + parameters.every((parameter) => { + const trimmed = trimOws(parameter); + return trimmed === "" || strictParameter.test(trimmed); + }) + ); +} + +/** A character that a reason phrase cannot have (as Node.js checks it) */ +const invalidReason = /[^\t\x20-\x7E\x80-\xFF]/u; + +/** The methods of `EventEmitter` that a response replaces (see its static block) */ +const parent = ServerResponse.prototype; + +/** The methods of `EventEmitter` of every emitter (e.g. of a piped source) */ +const intrinsic = EventEmitter.prototype; + +/** + * The methods that binden or the pipe of Node.js call on a piped source + * (e.g. `resume()`, which `on("data")` calls before the pipe is adopted): a + * source that has its own one (other than the patches of an open window, see + * `openWindow()`) is not adopted (see `BindenResponse.#adopt()`) + */ +const inspected = [ + ...adderNames, + "emit", + "pipe", + "unpipe", + "pause", + "resume", + "read", + "rawListeners", + "listeners", + "listenerCount", + "removeListener", + "off", + "removeAllListeners", +]; + +/** + * The listener methods that an instrumentation sets on a source to bind its + * listeners (e.g. `context.bind()` of OpenTelemetry): a pipe of the stack + * whose source has its own ones, and no other method of its own, may be left + * to Node.js (see `BindenResponse.#leave()`) + */ +const wrappers: ReadonlySet = new Set([ + ...adderNames, + "removeListener", + "off", + "removeAllListeners", +]); + +/** + * Why a pipe cannot be adopted (see `BindenResponse.#refusal()`), and + * whether it may be left to Node.js instead (see `BindenResponse.#leave()`) + */ +interface IRefusal { + readonly reason: string; + readonly native: boolean; +} + +/** A pipe that cannot be adopted, nor left to Node.js */ +function refused(reason: string): IRefusal { + return { reason, native: false }; +} + +/** + * The properties of a piped source that Node.js reads as it pipes it (its + * state, its listener table and the inspected methods), and binden as it + * adopts the pipe: an accessor of the source itself (e.g. a getter of + * `_readableState` that adds a listener after the one of the pipe) would + * run code of another work during the pipe (see `#refusal()`) + */ +const read: readonly string[] = [ + "_readableState", + "_writableState", + "_events", + "_eventsCount", + "_maxListeners", + "readableEnded", + "destroyed", + ...inspected, +]; + +/** + * The first property that Node.js or binden read on a piped source (see + * `read`) that the source has as an accessor of its own (none on a source + * that is not a proxy runs its code) + */ +function accessorOf(source: object): string | undefined { + return read.find((name) => { + const descriptor = Object.getOwnPropertyDescriptor(source, name); + return typeof descriptor !== "undefined" && !("value" in descriptor); + }); +} + +/** How a response handles the works that own it without the framework (see `kOwnership`) */ +const unowned: IOwnership = { + fail: (): boolean => false, + guards: (): boolean => false, + replier: (): null => null, + native: (): boolean => false, + debug: (): void => { + // Nobody to tell + }, + warn: (): void => { + // Nobody to tell + }, + note: (): void => { + // Nobody to tell + }, +}; + +/** + * The codes of the errors that a `write()` or an `end()` of Node.js throws + * once the head is committed which leave every byte that it has queued valid + * (see `BindenResponse.#stopping()`): a body that the response may not have + * (to `HEAD`, or with a 204 or a 304 status, under the + * `rejectNonStandardBodyWrites` option of the server), whose output is only + * its head, and the checks of a chunk before Node.js takes it + */ +const keeping: ReadonlySet = new Set([ + "ERR_HTTP_BODY_NOT_ALLOWED", + "ERR_INVALID_ARG_TYPE", + "ERR_STREAM_NULL_VALUES", + "ERR_UNKNOWN_ENCODING", +]); + +/** + * `Readable.prototype.destroy()`, which releases a `Readable` that is not + * sent (see `BindenResponse.#discard()`): never its own + */ +const destroyReadable = Reflect.get(Readable.prototype, "destroy") as ( + this: Readable, + error?: unknown, +) => unknown; + +/** + * The getter of `destroyed` of `Readable.prototype`: whether a `Readable` is + * destroyed, as Node.js tells it (never by a getter of its own) + */ +const readableDestroyed = Reflect.get( + Object(Reflect.getOwnPropertyDescriptor(Readable.prototype, "destroyed")), + "get", +) as (this: Readable) => boolean; + +/** The getter of a property of an object of the engine (e.g. of a prototype) */ +function getterOf(target: object, key: string): () => unknown { + return Reflect.get( + Object(Reflect.getOwnPropertyDescriptor(target, key)), + "get", + ) as () => unknown; +} + +/** + * The getters of the engine that tell where the bytes of a view are: of its + * `buffer`, its `byteOffset` and its `byteLength` + */ +type IViewGetters = readonly [ + buffer: () => unknown, + byteOffset: () => unknown, + byteLength: () => unknown, +]; + +/** The getters of a view of the engine (of a prototype, see `IViewGetters`) */ +function viewGetters(prototype: object): IViewGetters { + return [ + getterOf(prototype, "buffer"), + getterOf(prototype, "byteOffset"), + getterOf(prototype, "byteLength"), + ]; +} + +/** The getters of the typed arrays (of `%TypedArray%.prototype`, e.g. of a `Uint8Array`) */ +const typedArrayGetters = viewGetters( + Reflect.getPrototypeOf(Uint8Array.prototype) as object, +); + +/** The getters of a `DataView` */ +const dataViewGetters = viewGetters(DataView.prototype); + +/** The getter of `detached` of an `ArrayBuffer` */ +const arrayBufferDetached = getterOf(ArrayBuffer.prototype, "detached"); + +/** `Buffer.prototype`, read once (see `bytesOf()`) */ +const bufferPrototype = Reflect.get(Buffer, "prototype") as object; + +/** The refusal of a view of an `ArrayBuffer` that has been detached (see `bytesOf()`) */ +function detachedView(): TypeError { + return formatStack( + new TypeError("The data is a view of a detached ArrayBuffer"), + ); +} + +/** + * Where the bytes of a view are in its `ArrayBuffer` (see `bytesOf()`): its + * `byteOffset` and its `byteLength`, by the getters of the engine. A + * `DataView` out of the bounds of a resizable `ArrayBuffer` that has shrunk, + * whose bounds the engine does not tell, throws a `TypeError` of binden, + * formatted (see `formatStack()`) + */ +function boundsOf( + view: ArrayBufferView, + [, byteOffset, byteLength]: IViewGetters, +): [number, number] { + try { + return [ + Reflect.apply(byteOffset, view, []) as number, + Reflect.apply(byteLength, view, []) as number, + ]; + } catch { + throw formatStack( + new TypeError("The data is a view out of the bounds of its ArrayBuffer"), + ); + } +} + +/** + * The bytes of a view of an `ArrayBuffer` (a typed array or a `DataView`), + * as a `Buffer` of binden over them, which is what is sized and written: + * Node.js reads nothing of the view then. Its `buffer`, `byteOffset` and + * `byteLength` are read once, by the getters of the engine, never by a getter + * of the view's own (which may lie, or detach its buffer as it is read), and + * the `Buffer` is made by `Uint8Array` of the engine (as `Buffer.from()` + * makes one, but without reading a `byteLength` of the `ArrayBuffer`, which + * may be a getter of its own). The bytes of an `ArrayBuffer` that has been + * detached (e.g. by its `transfer()`) are gone, and Node.js would throw once + * it writes them (e.g. as it uncorks the connection on the next tick, out of + * any call: the process crashes): such a view throws a `TypeError` of binden, + * formatted (see `formatStack()`), and so does a `DataView` out of the bounds + * of a resizable `ArrayBuffer` that has shrunk (see `boundsOf()`; a typed + * array out of its bounds has no bytes, as the engine tells). A + * `SharedArrayBuffer` is never detached. + */ +function bytesOf(view: ArrayBufferView): Buffer { + const getters = isDataView(view) ? dataViewGetters : typedArrayGetters; + const [bufferOf] = getters; + const buffer = Reflect.apply(bufferOf, view, []); + if ( + isArrayBuffer(buffer) && + Reflect.apply(arrayBufferDetached, buffer, []) === true + ) { + throw detachedView(); + } + // As `Buffer.from()` makes it (a `Uint8Array` of `Buffer.prototype`) + return Object.setPrototypeOf( + new Uint8Array(buffer as ArrayBufferLike, ...boundsOf(view, getters)), + bufferPrototype, + ) as Buffer; +} + +/** + * Whether bytes that binden has counted (`size`, see `bytesOf()`) are gone + * once their `write()` has returned, as the getters of the engine tell them: + * the `ArrayBuffer` of the `Buffer` of binden, which shares the memory of the + * view of the application, has been detached, or the `Buffer` has fewer bytes + * (e.g. out of the bounds of a resizable `ArrayBuffer` that has shrunk). + * Application code runs inside that `write()` before the bytes are queued + * (e.g. the `toString()` of a cookie as the head is committed, or the + * `filter` of `@binden/compression`), and may detach them: the head would then + * declare bytes that are never written (see `BindenResponse.#cutLost()`). + */ +function bytesLost(bytes: Buffer, size: number): boolean { + const [bufferOf, , byteLength] = typedArrayGetters; + const buffer = Reflect.apply(bufferOf, bytes, []); + return ( + size > 0 && + ((isArrayBuffer(buffer) && + Reflect.apply(arrayBufferDetached, buffer, []) === true) || + (Reflect.apply(byteLength, bytes, []) as number) < size) + ); +} + +/** + * The JSON text of a value (`JSON.stringify()`). What it throws (e.g. the + * `TypeError` of a nested `bigint` or of a cycle, which V8 creates while the + * frames of the response are on the stack) is formatted (see + * `formatStack()`), so that an application that keeps it keeps none of them. + */ +function jsonText(data: unknown): string | undefined { + try { + return JSON.stringify(data); + } catch (error: unknown) { + throw formatStack(error as Error); + } +} + +/** + * A copy of a value read once: an array, by its indexes (a hole is an + * element), its elements that are arrays copied as well down to `depth` + * levels; anything else as it is + */ +function copied(value: unknown, depth: number): unknown { + if (depth === 0 || !Array.isArray(value)) { + return value; + } + const copy: unknown[] = []; + const { length } = value; + for (let index = 0; index < length; index += 1) { + copy.push(copied(value[index], depth - 1)); + } + return copy; +} + +/** + * The headers given to `writeHead()`, read once, as Node.js reads them: an + * array by its indexes, as are its pairs and the values of a header (see + * `copied()`), and an object (or a function) by its own enumerable + * properties, each array of values copied. Node.js then gets the copies, + * which hold exactly the values that binden has read and checked (a getter + * of the application, or a proxy, never runs again). Anything else (e.g. + * `undefined`, or a string, which Node.js reads as an object of its + * characters) is kept as it is. + */ +function headersOf(given: unknown): unknown { + if (Array.isArray(given)) { + return copied(given, 3); + } else if ( + (typeof given === "object" && given !== null) || + typeof given === "function" + ) { + return Object.fromEntries( + Object.entries(given).map(([name, value]) => [name, copied(value, 1)]), + ); + } + return given; +} + +/** + * The hints of `writeEarlyHints()`, read once, as Node.js reads them: an + * object (but an array, or a function, which Node.js refuses as it is) by its + * `link`, then, unless it has none (Node.js then sends nothing), by its own + * enumerable properties, each array of values copied (see `copied()`). + * Node.js then gets the copy. + */ +function hintsOf(hints: unknown): unknown { + if (typeof hints !== "object" || hints === null || Array.isArray(hints)) { + return hints; + } + const link: unknown = Reflect.get(hints, "link"); + if (link === null || typeof link === "undefined") { + return { link }; + } + return Object.fromEntries([ + ["link", copied(link, 1)], + ...Object.keys(hints) + .filter((name) => name !== "link") + .map((name) => [name, copied(Reflect.get(hints, name), 1)]), + ]); +} + +/** + * The trailers of `addTrailers()`, read once, as Node.js reads them: by their + * own enumerable keys, an array of pairs (each pair by its name and its + * value, at the same keys) or an object, each array of values copied (see + * `copied()`). Node.js then gets the copy. Anything else (e.g. `null`, which + * Node.js refuses) is kept as it is. + */ +function trailersOf(trailers: unknown): unknown { + if (typeof trailers !== "object" || trailers === null) { + return trailers; + } else if (!Array.isArray(trailers)) { + return Object.fromEntries( + Object.keys(trailers).map((name) => [ + name, + copied(Reflect.get(trailers, name), 1), + ]), + ); + } + const copy: unknown[] = []; + for (const key of Object.keys(trailers)) { + const pair: unknown = Reflect.get(trailers, key); + Reflect.set( + copy, + key, + typeof pair === "object" && pair !== null + ? [Reflect.get(pair, 0), copied(Reflect.get(pair, 1), 1)] + : pair, + ); + } + return copy; +} + +/** + * Whether a value of a header (see `headersOf()`) is checked without running + * code of the application: anything but an object or a function, or an + * array of such values. A value of another type is checked by Node.js + * itself, which converts it as it does without binden. + */ +function inert(value: unknown): boolean { + const primitive = (element: unknown): boolean => + (typeof element !== "object" || element === null) && + typeof element !== "function"; + return Array.isArray(value) ? value.every(primitive) : primitive(value); +} + +/** The serialization of `URLSearchParams` of Node.js (see `formText()`) */ +const searchParamsText = Reflect.get(URLSearchParams.prototype, "toString"); + +/** + * The text of `URLSearchParams` (`application/x-www-form-urlencoded`), by the + * serialization of Node.js. Anything else (e.g. `null`, a string, or an object + * that only looks like them) is refused with a `TypeError` of binden: the + * error of Node.js (or of V8), created while the frames of the response are + * on the stack, is not kept (see `formatStack()`). + */ +function formText(params: unknown): string { + try { + return Reflect.apply(searchParamsText, params, []); + } catch { + throw formatStack(new TypeError("The parameters are not URLSearchParams")); + } +} + +/** The report of a late error of a source of a response that is not owned */ +function unreported(): void { + // Nobody to tell +} + +/** The listeners of an event of an emitter, as added (e.g. a wrapper of `once()`) */ +function rawListenersOf(emitter: EventEmitter, event: string): IListener[] { + return intrinsic.rawListeners.call(emitter, event) as IListener[]; +} + +/** The state of a readable stream that binden reads (see `pipesInto()`) */ +interface IReadableState { + readonly pipes?: unknown; + readonly pipesCount?: unknown; + readonly endEmitted?: unknown; +} + +/** The state of a readable stream, if it has one */ +function stateOf(source: EventEmitter): IReadableState | undefined { + const { _readableState: state } = source as { + _readableState?: IReadableState; + }; + return state; +} + +/** + * Whether a source is piped into a destination by `Readable.prototype.pipe()` + * (its state lists it): in the list of its pipes, or as its only one, which + * the state of Node.js 10 (also of `readable-stream` 2 and 3) holds as is + */ +function pipesInto(source: EventEmitter, destination: object): boolean { + const state = stateOf(source); + if (typeof state === "undefined") { + return false; + } + const { pipes, pipesCount } = state; + return Array.isArray(pipes) + ? pipes.includes(destination) + : pipes === destination && pipesCount === 1; +} + +/** + * Whether a source has emitted its `end`: `readableEnded`, or the state of + * a stream that has no such property (e.g. of `readable-stream` 2 and 3) + */ +function hasEnded(source: EventEmitter): boolean { + return ( + (source as Partial).readableEnded === true || + stateOf(source)?.endEmitted === true + ); +} + +/** + * Unpipes a source from a destination by the method of its class (never by + * one of the source itself), if it is still piped into it: `false` when it + * is not (e.g. a legacy pipe of `Stream.prototype.pipe()`, which no state + * lists), or its class has no such method + */ +function unpipe(source: EventEmitter, destination: object): boolean { + const method: unknown = Reflect.get( + Object.getPrototypeOf(source) as object, + "unpipe", + ); + if (typeof method === "function" && pipesInto(source, destination)) { + Reflect.apply(method, source, [destination]); + return true; + } + return false; +} + +/** + * Replaces the last listener of an event of a source in its place, by the + * methods of every emitter: no listener of the source runs (it has none of + * `newListener` or `removeListener`, see `BindenResponse.#refusal()`) + */ +function replaceListener( + source: EventEmitter, + event: string, + listener: IListener, + bound: IListener, +): void { + intrinsic.removeListener.call(source, event, listener); + intrinsic.on.call(source, event, bound); +} + +/** + * The arguments of a method of Node.js whose callback runs as the work that + * has called it (see `bind()`): Node.js calls it once the socket has written, + * as whoever has flushed it then + */ +function callbacksOf(args: readonly unknown[]): unknown[] { + const owner = owners.getStore(); + const bound: unknown[] = []; + for (const arg of args) { + bound.push( + typeof arg === "function" + ? bind(arg as IListener, owner, "callback", null) + : arg, + ); + } + return bound; +} + +/** An observer of an event of a response (see `[kListen]()`) */ +interface IObserver { + readonly listener: IListener; + /** The work that has added it */ + readonly owner: object | undefined; + readonly once: boolean; + /** Whether it has run, once it runs once */ + fired: boolean; +} + +/** + * Adds the listeners of a send to its source, by the methods of the source, + * and records them (see `BindenResponse.#pipe()`) + */ +interface IListening { + on(event: string, listener: (...args: never[]) => void): IListening; +} + +/** + * The method that removes a listener that a send has added to a Node.js + * `Readable` by its own `on()` (see `BindenResponse.#pipe()`): its own + * `removeListener()`, or else its own `off()`, which pairs with that `on()` + * (e.g. finds the listener that a wrapping `on()` has added in its place), + * or else the one of `EventEmitter` + */ +function removerOf(source: Readable): (...args: unknown[]) => unknown { + for (const name of ["removeListener", "off"]) { + const method: unknown = Reflect.get(source, name); + if (typeof method === "function") { + return method as (...args: unknown[]) => unknown; + } + } + return emitterMethods.remove; +} + +/** The pipe whose adopted `data` listener runs now (see `destroy()`) */ +interface IMarker { + readonly owner: object | undefined; + readonly source: EventEmitter; +} + +/** + * The properties of Node.js that frame a response (its connection and its + * body), which it reads as it commits the headers and writes the body + */ +interface IConnection { + chunkedEncoding: boolean; + maxRequestsOnConnectionReached: boolean; + sendDate: boolean; + shouldKeepAlive: boolean; + strictContentLength: boolean; + useChunkedEncodingByDefault: boolean; +} + +/** Their values as the constructor of `OutgoingMessage` sets them */ +const initial: Readonly = Object.freeze({ + chunkedEncoding: false, + maxRequestsOnConnectionReached: false, + sendDate: false, + shouldKeepAlive: true, + strictContentLength: false, + useChunkedEncodingByDefault: true, +}); + +/** + * The framing properties of the responses (see `IConnection`): the + * constructors of Node.js set them before the private fields of a response + * exist + */ +const connections = new WeakMap(); + +/** Whether a value is truthy, as Node.js tests a chunk or an encoding (e.g. `""` is none) */ +const truthy = (value: unknown): boolean => Boolean(value); + +/** + * The encodings of the strings whose length Node.js only estimates (e.g. of + * an invalid one): a string in one of them is written as its bytes (see + * `BindenResponse.#normalized()`) + */ +const estimated: ReadonlySet = new Set(["base64", "base64url", "hex"]); + +/** The headers that frame a body (see `BindenResponse.#framing`) */ +const framing: ReadonlySet = new Set([ + "content-length", + "content-range", + "transfer-encoding", +]); + +/** Without `[kSeal]()`, nothing may mutate a sealed response */ +const never = (): boolean => false; + +/** The representation of a body that is sent as it is (see `[kRepresentation]()`) */ +const identity: IRepresentation = Object.freeze({ coding: null, status: null }); + +/** What the operations of a response need from it (see `Operation`) */ +interface IRights { + /** Whether the work of an owner may mutate the response */ + allows(owner: object | undefined): boolean; + /** Runs a function with the rights of the framework */ + force(fn: () => T): T; + /** Whether the response has been ended */ + ended(): boolean; +} + +/** + * An operation on a response (a send of a stream, a file, a compressor): it + * belongs to the owner of the work that has started it, whose rights its + * asynchronous callbacks keep (see `IResponseOperation`). What its methods + * throw is formatted (see `formatStack()`), e.g. for a callback that is not + * a function, or what the callback throws. + */ +class Operation implements IResponseOperation { + readonly #owner: object | undefined; + readonly #rights: IRights; + #transfer = false; + + public constructor(owner: object | undefined, rights: IRights) { + this.#owner = owner; + this.#rights = rights; + } + + public get active(): boolean { + return this.#transfer || this.#rights.allows(this.#owner); + } + + public run(fn: () => T): T { + // Inline: a hot path (e.g. each chunk of a compressed response) + try { + if (this.#transfer) { + const rights = this.#rights; + return owners.run(this.#owner as object, () => rights.force(fn)); + } + return owners.run(this.#owner as object, fn); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + public transfer(): void { + try { + if (this.active && this.#rights.ended()) { + this.#transfer = true; + } + } catch (error: unknown) { + throw formatStack(error as Error); + } + } +} + +/** + * The cookies of a response: once the response is sealed, the abandoned work + * no longer changes them (also through a set obtained before) + */ +class Cookies extends Set { + readonly #allows: () => boolean; + + public constructor(allows: () => boolean) { + super(); + this.#allows = allows; + } + + /** + * Adds a cookie (a `Cookie`, or an object whose `toString()` gives the value + * of its `Set-Cookie`, read as the head is committed), unless the response + * is sealed: anything but an object (e.g. `null`, or a string) is refused + * with a `TypeError` that describes it (see `objectOf()`) + */ + public override add(cookie: Cookie): this { + if (!this.#allows()) { + return this; + } + objectOf(cookie, "cookie"); + return super.add(cookie); + } + + public override delete(cookie: Cookie): boolean { + return this.#allows() && super.delete(cookie); + } + + public override clear(): void { + if (this.#allows()) { + super.clear(); + } + } +} + +/** + * `ServerResponse`, whose constructor formats what it throws (see + * `formatStack()`), e.g. the `TypeError` of a request that is not an object, + * which Node.js reads: the constructor of `BindenResponse`, which has private + * fields, can only call it at its root, outside of a `try` + */ +const FormattedResponse = class extends ServerResponse { + public constructor(...args: [request: IncomingMessage, options?: unknown]) { + try { + super(...(args as [IncomingMessage])); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } +} as unknown as typeof ServerResponse; + +/** + * 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 FormattedResponse { + #cookies?: Cookies; + /** Whether a stream has been piped into the response (by its owner) */ + #piped = false; + /** + * The sends in progress (of a stream or a file), by their key, with the + * function that cancels them: the send rejects with the reason, and its + * source is released (see `[kCancel]()`, which restores the response + * first, see `#rollbacks`) + */ + readonly #pending = new Map< + object, + (reason: unknown, release: (() => void)[]) => void + >(); + /** + * What restores the response as it was before each send in progress (see + * `#pending`), by its key, in the order of their start: run by + * `[kCancel]()` in the reverse order, before the reply that replaces them. + * A send that settles once its cancellation has been recorded keeps it + * until then (see `#settled()`), so that this reply never inherits what it + * has set. `null` for a send of a stream, which has nothing of its own to + * restore: `[kCancel]()` removes the framing of the bodies that are not + * sent, and the representation chosen for them, once for every send. A + * rollback holds nothing of its send (e.g. its source), which may have + * settled long before. + */ + readonly #rollbacks = new Map void) | null>(); + /** + * The cancellations recorded for sends in progress (see + * `[kCancelling]()`), by their key: such a send rejects with the reason, + * however it settles, and is still restored by `[kCancel]()` + */ + readonly #cancellations = new WeakMap(); + /** The checks of the owners that may mutate the response (see `[kSeal]()`) */ + readonly #seals: ((owner: object | undefined) => boolean)[] = []; + /** How the framework handles the works that own the response (see `[kOwnership]()`) */ + #ownership: IOwnership = unowned; + /** + * The works that may no longer mutate the response (see `[kRevoke]()`): + * whether they have been told so + */ + readonly #revoked = new Map(); + /** The sources that each work has piped into the response (see `#adopt()`) */ + readonly #sources = new Map>(); + /** The listeners that guard a piped source (see `#guard()`) */ + readonly #guards = new Map(); + /** + * The sources that each work has piped, left to Node.js (see `#leave()`), + * with the reason + */ + readonly #natives = new Map>(); + /** The works whose re-emitted `error` is absorbed now (see `#deliver()`) */ + readonly #absorbing = new Set(); + /** The pipe whose adopted `data` listener runs now (see `destroy()`) */ + #marker: IMarker | null = null; + /** The observers of the framework, by event (see `[kListen]()`) */ + readonly #observers = new Map(); + #rights?: IRights; + /** How a library chooses the representation of the body (see `[kNegotiator]()`) */ + #negotiator: IRepresentationNegotiator | null = null; + #statusCode = 200; + #statusMessage: string | undefined; + /** + * The generation of the framing headers of the response, advanced by every + * change of them that Node.js has accepted: a send that fails removes its + * framing only while it is the latest (see `#pipe()`) + */ + #framing = 0; + /** + * Whether the framework has destroyed the response before it has ended: + * its socket, once Node.js assigns one, is destroyed (see `#cutQueued()`) + */ + #cut = false; + /** + * The writes that a library that has replaced the response has discarded + * (see `[kDiscardWrite]()`, and `#dropped()`) + */ + #discardedWrites = 0; + + static { + // A response made by the constructor (see `isResponse()`) + brandResponses((value) => #observers in value); + // The status of a sealed response is not changed by the abandoned work + // either (also by an assignment, which Node.js itself uses) + Object.defineProperties(this.prototype, { + statusCode: { + configurable: true, + enumerable: true, + get(this: BindenResponse): number { + return this.#statusCode; + }, + set(this: BindenResponse, code: number): void { + if (this.#allows()) { + this.#statusCode = code; + } + }, + }, + statusMessage: { + configurable: true, + enumerable: true, + get(this: BindenResponse): string | undefined { + return this.#statusMessage; + }, + set(this: BindenResponse, message: string | undefined): void { + if (this.#allows()) { + this.#statusMessage = message; + } + }, + }, + }); + // What the methods of the listeners throw (e.g. the error of Node.js + // for a listener that is not a function, of a listener itself, or of an + // event name that it cannot convert) is formatted (see `formatStack()`), + // inline: they are hot paths + function rawListeners( + this: BindenResponse, + event: string | symbol, + ): IListener[] { + try { + return rawListenersOf(this, event as string).map((raw) => { + const binding = bindingOf(raw); + return binding && !binding.once ? binding.listener : raw; + }); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + function emit( + this: BindenResponse, + event: string | symbol, + ...args: unknown[] + ): boolean { + try { + if (event === "pipe") { + this.#piping(args[0]); + } else if (event === "unpipe") { + this.#unpiped(args[0]); + } else if (event === "socket") { + // Before its listeners, and before Node.js flushes the response + this.#assigned(args[0] as Duplex); + } else if ( + event === "error" && + this.#absorbing.has(owners.getStore() as object) + ) { + // Re-emitted by a listener of an abandoned work (e.g. the pipe of + // Node.js once it has unpiped): the framework handles the error + return false; + } + return this.#emit(event, args); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + // The listeners added by the application run as the work that has added + // them (see `bind()`), never as whoever emits their event. Replaced at + // run time only: the types are those of Node.js. + const add = ( + name: "addListener" | "on" | "prependListener", + once: boolean, + ): (( + this: BindenResponse, + event: string | symbol, + listener: IListener, + ) => BindenResponse) => + function listen(this: BindenResponse, event, listener) { + try { + const owner = owners.getStore(); + const bound = bind( + listener, + owner, + "user", + once ? { emitter: this, event } : null, + ); + const result = parent[name].call( + this, + event, + bound, + ) as BindenResponse; + if (event === "close") { + this.#late(owner, bound); + } + return result; + } catch (error: unknown) { + throw formatStack(error as Error); + } + }; + // Writable, as the methods of Node.js: an instrumentation may wrap them + // on a response (its wrapper calls them) + const method = (value: unknown): PropertyDescriptor => ({ + configurable: true, + writable: true, + value, + }); + Object.defineProperties(this.prototype, { + addListener: method(add("addListener", false)), + on: method(add("on", false)), + once: method(add("on", true)), + prependListener: method(add("prependListener", false)), + prependOnceListener: method(add("prependListener", true)), + // As Node.js: the listener itself, but the wrapper of a `once()` + rawListeners: method(rawListeners), + // A source piped into the response is adopted before any listener of + // `pipe` runs (see `#adopt()`) + emit: method(emit), + }); + // Nor how it is framed: whether it sends a `Date`, keeps its connection + // open (or says that it closes it), chunks its body (also by default) or + // checks its length. Node.js sets them in the calls of the work that may + // mutate the response (e.g. as it commits the headers), the framework + // with its rights (e.g. at a shutdown). The other fields of Node.js are + // its own state (e.g. `finished`, `outputData`, and those whose name + // starts with `_`, such as `_hasBody`), which only its code writes: an + // application never assigns them. + for (const name of Object.keys(initial) as (keyof IConnection)[]) { + Object.defineProperty(this.prototype, name, { + configurable: true, + enumerable: true, + get(this: BindenResponse): boolean { + return (connections.get(this) as IConnection)[name]; + }, + set(this: BindenResponse, value: boolean): void { + // Set by the constructors of Node.js before the fields exist + if (#seals in this && !this.#allows()) { + return; + } + const connection = connections.get(this); + if (connection) { + connection[name] = value; + } else { + connections.set(this, { ...initial, [name]: value }); + } + }, + }); + } + } + + /** 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])); + } + + /** + * A source is piped into the response (`pipe` of `Readable.pipe()`, emitted + * synchronously in the work that pipes): it starts the response of a work + * that may mutate it, and is adopted (see `#adopt()`) + */ + #piping(source: unknown): void { + const owner = owners.getStore(); + if (isProxy(source)) { + // Before any inspection (even `instanceof`), which would run its traps + this.#refuse(source as EventEmitter, owner, "the source is a proxy"); + } else if (source instanceof EventEmitter) { + this.#adopt(source as EventEmitter, owner); + } + } + + /** + * Adopts a source that `Readable.prototype.pipe()` has just piped into the + * response, before any listener of `pipe` runs: its writes (the last `data` + * listener, which the pipe has just added) and its end (the last `end` + * listener, unless it has ended already: its end is then scheduled in the + * work that pipes) run as the work that has piped it, whose rights they + * keep, whatever work pushes its chunks. Until the end of the turn, the + * listeners added to the source are bound to the work that adds them (see + * `openWindow()`), and a source piped by an error handler that fails before + * its end fails it (see `#guard()`). + * + * The pipe is adopted only when nothing but the class of the source (which + * is trusted, as the stream that the work has chosen) can have added a + * listener during the pipe (see `#refusal()`): otherwise it is refused + * (see `#refuse()`), and the work that has piped it fails, unless it is a + * pipe of the stack that is left to Node.js (see `#leave()`). + */ + #adopt(source: EventEmitter, owner: object | undefined): void { + const refusal = this.#refusal(source); + if (refusal !== null) { + if ( + refusal.native && + typeof owner !== "undefined" && + this.#ownership.native(owner) && + this.#allows(owner) + ) { + this.#leave(source, owner, refusal.reason); + } else { + this.#refuse(source, owner, refusal.reason); + } + return; + } else if (!openWindow(source)) { + this.#refuse(source, owner, "its listeners cannot be bound"); + return; + } + this.#start(owner); + this.#sourcesOf(owner).add(source); + // A write of the pipe that throws (e.g. a head that Node.js refuses) is + // caught by the pipe, which destroys the response with it (see + // `destroy()`); by a pipe that does not catch it (e.g. of + // `readable-stream`, whose source would get it), it fails the pipe here + const data = rawListenersOf(source, "data").at(-1) as IListener; + replaceListener( + source, + "data", + data, + bind(bindingOf(data)?.listener ?? data, owner, "source", null, (call) => { + const marker = this.#marker; + this.#marker = { owner, source }; + try { + return call(); + } catch (error: unknown) { + if (this.#pipeFailed(owner, error, source)) { + return null; + } + throw error; + } finally { + this.#marker = marker; + } + }), + ); + if (!hasEnded(source)) { + // Its end (e.g. `dest.end()` into a head that Node.js refuses) throws + // out of the events of the source: the pipe fails instead + const end = rawListenersOf(source, "end").at(-1) as IListener & { + listener: IListener; + }; + replaceListener( + source, + "end", + end, + bind( + end.listener, + owner, + "source", + { emitter: source, event: "end" }, + (call) => { + try { + return call(); + } catch (error: unknown) { + if (this.#pipeFailed(owner, error, source)) { + return null; + } + throw error; + } + }, + ), + ); + } + this.#guard(source, owner); + } + + /** + * A pipe that may write starts the response of a work that may mutate it + * (see `started`): never one that is refused, which writes nothing + */ + #start(owner: object | undefined): void { + if (this.#allows(owner)) { + this.#piped = true; + } + } + + /** The sources piped by a work (see `#adopt()`) */ + #sourcesOf(owner: object | undefined): Set { + let sources = this.#sources.get(owner); + if (typeof sources === "undefined") { + sources = new Set(); + this.#sources.set(owner, sources); + } + return sources; + } + + /** + * Why a pipe cannot be adopted (`null` when it can): code other than the + * class of the source may have added a listener during the pipe (a + * listener of `newListener` or `removeListener` of the source or of the + * response, or a method or an accessor of the source itself that the pipe + * calls or reads, see `read`), or it is not a pipe of + * `Readable.prototype.pipe()` (e.g. the legacy + * `Stream.prototype.pipe()`), whose last `data` listener and `end` wrapper + * it has just added. The source is inspected by the methods of every + * emitter, never by its own ones. A source whose only methods of its own + * are listener methods (see `wrappers`, e.g. bound by an instrumentation), + * and that passes every other check, may be left to Node.js (`native`). + */ + #refusal(source: EventEmitter): IRefusal | null { + const listens = (emitter: EventEmitter): boolean => + intrinsic.listenerCount.call(emitter, "newListener") + + intrinsic.listenerCount.call(emitter, "removeListener") > + 0; + if (listens(source)) { + return refused("the source has listeners of its listeners"); + } else if (listens(this)) { + return refused("the response has listeners of its listeners"); + } + const accessor = accessorOf(source); + if (typeof accessor !== "undefined") { + return refused(`the source has an accessor of its own (${accessor})`); + } + const own = inspected.filter( + (name) => Object.hasOwn(source, name) && !isPatch(source, name), + ); + const other = own.find((name) => !wrappers.has(name)); + if (typeof other !== "undefined") { + return refused(`the source has its own ${other}()`); + } else if (!pipesInto(source, this)) { + return refused("it is not a pipe of Readable.prototype.pipe()"); + } + const data = rawListenersOf(source, "data").at(-1); + const plain = + bindingOf(data)?.once === false || + (typeof data === "function" && !("listener" in data)); + if (!plain) { + return refused("its data listener is not the one of the pipe"); + } + const end = rawListenersOf(source, "end").at(-1); + const once = + typeof end === "function" && + typeof (end as { listener?: unknown }).listener === "function"; + if (!once && !hasEnded(source)) { + return refused("its end listener is not the one of the pipe"); + } + const [wrapper] = own; + return typeof wrapper === "undefined" + ? null + : { reason: `the source has its own ${wrapper}()`, native: true }; + } + + /** + * Leaves a pipe of the stack to Node.js (see `IOwnership.native()`), while + * the stack may mutate the response: its source has listener methods of + * its own (e.g. bound by the context of an instrumentation, such as + * `context.bind()` of OpenTelemetry), which may have added any listener + * during the pipe, so that nothing of it can be attributed. It is not + * adopted (nothing of it gets the rights of the stack), and runs as without + * binden: before the exchange is sealed, every work may mutate the + * response. It is kept, to be dismantled once the stack is revoked (see + * `#dismantle()`); a pipe of a stack that has been revoked is refused. + */ + #leave(source: EventEmitter, owner: object, reason: string): void { + this.#start(owner); + let sources = this.#natives.get(owner); + if (typeof sources === "undefined") { + sources = new Map(); + this.#natives.set(owner, sources); + } + sources.set(source, reason); + this.#ownership.note("A pipe has been left to Node.js", reason); + } + + /** + * Dismantles the pipes of a revoked work that it has left to Node.js (see + * `#leave()`), before anything replaces the reply of the work: each source + * is unpiped by the method of its class, as that work. Nothing proves that + * no callback of such a pipe is left, whatever its source reports (e.g. + * that it has ended): its methods of its own may have kept a listener of + * the pipe, or wrapped one (e.g. its end, which would end the reply that + * replaces it). Unless the response has ended (its reply is complete: + * nothing replaces it), the response and its connection are closed, with + * the rights of the framework, so that nothing replaces the reply. + */ + #dismantle(owner: object, sources: ReadonlyMap): void { + for (const source of sources.keys()) { + try { + as(owner, () => { + unpipe(source, this); + }); + } catch (cleanup: unknown) { + const message = "The cleanup of a pipe left to Node.js has failed"; + this.#ownership.debug(message, cleanup); + } + } + if (!this.writableEnded) { + const message = + "A pipe left to Node.js by an abandoned work may still write. Destroying the socket"; + // Why the first one has been left to Node.js (see `#leave()`) + const [reason] = sources.values(); + this.#ownership.note(message, reason); + this[kFramework](() => { + this.destroy(); + }); + } + } + + /** + * Refuses a pipe (see `#refusal()`): the work that has piped it fails at + * once with a `TypeError` (see `#pipeFailed()`), which revokes it before + * anything runs code of the application, and closes the response for it + * (see `[kRevoke]()`). Then it is unpiped (by the method of the class of + * the source) in that work, which may no longer mutate the response (e.g. + * from a listener of `removeListener` of the source): a failure of that + * cleanup is only logged. A legacy pipe, which has no `unpipe()`, is + * dismantled by its `close` listener, which runs as that work once it is + * told. The pipe of a work that the framework does not know (e.g. of no + * request, or of another request), which it does not fail, is unpiped as + * well, and the response left to its owner; a legacy one cannot be (nor + * closed for such a work): it is left to Node.js, with a warning of its + * own. + */ + #refuse( + source: EventEmitter, + owner: object | undefined, + reason: string, + ): void { + // Formatted (see `formatStack()`): its work fails with it + const error = formatStack( + new TypeError(`The pipe into the response is refused: ${reason}`), + ); + const failed = this.#pipeFailed(owner, error, null); + let unpiped = false; + try { + unpiped = unpipe(source, this); + } catch (cleanup: unknown) { + const message = "The cleanup of a refused pipe has failed"; + this.#ownership.debug(message, cleanup); + } + if (failed) { + return; + } + const message = unpiped + ? "A pipe of a work of no request (or of another) has been refused" + : "A pipe of a work of no request (or of another) cannot be unpiped (e.g. a legacy pipe): it has been left to Node.js"; + this.#ownership.warn(message, reason); + } + + /** + * A source piped by a work that the framework guards (see + * `IOwnership.guards()`, e.g. an error handler): when it errors or closes + * before its end, the work fails (Node.js would leave the response open, + * the work waiting). Guarded until it is unpiped. + */ + #guard(source: EventEmitter, owner: object | undefined): void { + if (typeof owner === "undefined" || !this.#ownership.guards(owner)) { + return; + } + const failed = (error: unknown): void => { + if (!hasEnded(source)) { + this.#pipeFailed(owner, error, source); + } + }; + const guards: [string, IListener][] = [ + ["error", failed], + [ + "close", + (): void => { + failed(BindenResponse.#premature()); + }, + ], + ]; + this.#unguard(source); + for (const [event, listener] of guards) { + intrinsic.prependListener.call(source, event, listener); + } + this.#guards.set(source, guards); + } + + /** + * A source is unpiped: it is no longer guarded (see `#guard()`), nor kept + * to be unpiped (see `#deliver()`) + */ + #unpiped(source: unknown): void { + for (const sources of this.#sources.values()) { + sources.delete(source as EventEmitter); + } + this.#unguard(source); + } + + /** A source is no longer guarded (see `#guard()`) */ + #unguard(source: unknown): void { + const guards = this.#guards.get(source as EventEmitter); + if (typeof guards !== "undefined") { + this.#guards.delete(source as EventEmitter); + for (const [event, listener] of guards) { + intrinsic.removeListener.call(source, event, listener); + } + } + } + + /** + * A pipe of a work into the response has failed: the framework handles it + * (see `IOwnership.fail()`), unless it does not know the work (e.g. of no + * request). `true` once handled. Its source, if any, is unpiped once the + * work has been told (see `[kRevoke]()`). + */ + #pipeFailed( + owner: object | undefined, + error: unknown, + source: EventEmitter | null, + ): boolean { + if (source !== null) { + this.#sourcesOf(owner).add(source); + } + return typeof owner !== "undefined" && this.#ownership.fail(owner, error); + } + + /** + * A `close` listener of a work that has been told already that it may no + * longer mutate the response (see `[kRevoke]()`): it runs on the next tick, + * once, unless removed before + */ + #late(owner: object | undefined, bound: IListener): void { + if (typeof owner !== "undefined" && this.#revoked.get(owner) === true) { + process.nextTick(() => { + if (rawListenersOf(this, "close").includes(bound)) { + intrinsic.removeListener.call(this, "close", bound); + this.#safely(bound, []); + } + }); + } + } + + /** + * Tells a work that it may no longer mutate the response: its `error` + * listeners get the error of its failure (if any), then its `close` + * listeners run once (and are removed), as their own work, as if the + * response had been closed; the sources that it has piped and that are + * still piped are then unpiped (a failure of that cleanup, e.g. of a + * hostile source, is only logged). Never the listeners of another work, nor + * those of the framework, which get the real `close`. + */ + #deliver(owner: object, failure: [unknown] | null): void { + const own = (event: string): IListener[] => + rawListenersOf(this, event).filter((raw) => { + const binding = bindingOf(raw); + return binding?.kind === "user" && binding.owner === owner; + }); + this.#absorbing.add(owner); + try { + if (failure !== null) { + for (const listener of own("error")) { + this.#safely(listener, failure); + } + } + for (const listener of own("close")) { + intrinsic.removeListener.call(this, "close", listener); + this.#safely(listener, []); + } + } finally { + this.#absorbing.delete(owner); + } + for (const source of this.#sources.get(owner) ?? []) { + // Code of the application (e.g. a getter of the source, or its + // `unpipe()`), on a tick of its own: a failure is only logged, and the + // other sources are unpiped all the same + try { + unpipe(source, this); + } catch (error: unknown) { + const message = + "The unpipe of a source of an abandoned work has failed"; + this.#ownership.debug(message, error); + } + } + this.#sources.delete(owner); + const natives = this.#natives.get(owner); + if (typeof natives !== "undefined") { + this.#natives.delete(owner); + this.#dismantle(owner, natives); + } + } + + /** Runs a listener of an abandoned work: its failure is only logged */ + #safely(listener: IListener, args: unknown[]): void { + try { + Reflect.apply(listener, this, args); + } catch (error: unknown) { + const message = "A listener of an abandoned work has failed"; + this.#ownership.debug(message, error); + } + } + + /** + * 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 { + this.#cookies ??= new Cookies(() => this.#allows()); + return this.#cookies; + } + + /** + * Whether the response has been started: by `.send()` or `.sendFile()` + * (until they fail before sending anything, or are cancelled), by a stream + * piped into it (until a deadline cancels it before it has written + * anything; never by a refused pipe), or by its headers being sent + */ + public get started(): boolean { + return ( + this.#piped || + this.#pending.size > 0 || + 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 (anything else throws a + * `TypeError`, see `described()`) + */ + public status(code: number): this { + if (!this.#allows()) { + return this; + } else if (!isStatus(code)) { + throw formatStack( + new TypeError(`Status code ${described(code)} is invalid`), + ); + } + this.statusCode = code; + return this; + } + + /** + * Set headers: the own properties of the object (not inherited ones). + * Anything but an object (e.g. `null` or a string, whose characters would + * be headers) throws a `TypeError`, and so does what reading the object + * throws (e.g. of a revoked proxy), formatted (see `formatStack()`). + */ + public set(headers: IHeaders): this { + if (!this.#allows()) { + return this; + } + // Checked as given (e.g. `null` by JavaScript) + const given: unknown = headers; + if (typeof given !== "object" || given === null) { + throw formatStack(new TypeError("`headers` is not an object")); + } + for (const [name, value] of formatting(() => Object.entries(headers))) { + this.setHeader(name, value); + } + return this; + } + + /** + * Set the `Content-Type`: a media type whose parameters are each a token + * with a token or a quoted string as value (a `TypeError` otherwise) + */ + public type(media_type: string): this { + if (!this.#allows()) { + return this; + } + const parsed = + typeof media_type === "string" && isStrictMediaType(media_type) + ? ContentType.fromString(media_type) + : null; + if (parsed === null) { + throw formatStack( + new TypeError(`Invalid media type: ${described(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 { + if (!this.#allows()) { + return 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. A name that is + * not a token (also anything but a string) throws a `TypeError` before + * anything is changed. + */ + public vary(...fields: string[]): this { + if (!this.#allows()) { + return this; + } + for (const field of fields) { + // A string before the regular expression, which would convert it + const given: unknown = field; + if (typeof given !== "string" || !fieldName.test(given)) { + throw formatStack( + new TypeError(`Invalid field name: ${described(given)}`), + ); + } + } + 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(", ")); + } + + /** + * 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. + * An invalid name or attribute throws a `TypeError` (see `Cookie`), and so + * does what reading the attributes throws (e.g. of a revoked proxy), + * formatted (see `formatting()`). + */ + public clearCookie(name: string, attributes: IClearCookie = {}): this { + if (!this.#allows()) { + return this; + } + const given = formatting(() => ({ ...attributes })); + const cookie = new Cookie({ + ...given, + 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; + } + + /** + * 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; + /** Sends the headers without a reason phrase (as the first signature) */ + public override writeHead( + statusCode: number, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this; + public override writeHead( + statusCode: number, + reason?: OutgoingHttpHeader[] | OutgoingHttpHeaders | string, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this { + // What it throws is formatted (see `formatStack()`), e.g. the errors of + // Node.js for a status code or a header that it refuses, or what the + // `toString()` of a cookie throws + try { + return this.#writeHead(statusCode, reason, headers); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** `writeHead()`, which may throw an error that is not formatted */ + #writeHead( + statusCode: number, + reason?: OutgoingHttpHeader[] | OutgoingHttpHeaders | string, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this { + if (!this.#allows()) { + return this; + } else if (this.headersSent) { + // Node.js throws `ERR_HTTP_HEADERS_SENT` before it reads anything + return super.writeHead(statusCode, reason as string, headers); + } + // The status of the response itself (e.g. of a first `write()`, whose + // head Node.js commits by it): converted once, and the integer that + // Node.js commits is stored before anything else is checked (see + // `#converted()`), so that another attempt does not convert it again. A + // status given is converted by the check alone: a refused call changes + // nothing + let own = statusCode; + if ((statusCode as unknown) === (this.#statusCode as unknown)) { + this.#converted(); + own = this.#statusCode; + } + // Node.js would throw only once it has changed the response (its status + // and reason phrase, and some of the headers). Read once, and checked: + // Node.js gets exactly these (see `IHead`) + const [code, message, given] = this[kValidateHead](own, reason, headers); + const write = ( + fields: OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined, + ): this => { + if (typeof message === "string") { + return super.writeHead(code, message, fields); + } + return super.writeHead(code, fields); + }; + return this.#restoring(() => { + const values = this.#serialized(); + // None, or each has been dropped + if (this.cookies.size === 0) { + return write(given); + } + // Not sent when Node.js refuses the head (e.g. an invalid status code): + // the next attempt adds them + const [explicit, rest] = BindenResponse.#takeSetCookie(given); + this.setHeader("Set-Cookie", [ + ...(explicit ?? BindenResponse.#values(this.getHeader("Set-Cookie"))), + ...values, + ]); + return write(rest); + }); + } + + /** + * The `Set-Cookie` values of the cookies, read as the head is committed: + * what their `toString()` gives. While the framework replies an error (see + * `cookieGuards`), each is read once, in its own guard, and converted once + * as Node.js converts it in the head (e.g. a number, or a symbol, which + * throws): a cookie whose `toString()` throws (e.g. of a revoked proxy, or + * of an object derived from a `Cookie`), or gives a value that Node.js + * refuses in a header (e.g. with a line break), is dropped from the cookies + * and its error reported, and the others are sent + */ + #serialized(): string[] { + const cookies = [...this.cookies]; + const report = cookieGuards.get(this); + if (typeof report === "undefined") { + return cookies.map((cookie) => cookie.toString()); + } + const values: string[] = []; + for (const cookie of cookies) { + try { + const value: unknown = cookie.toString(); + const text = typeof value === "string" ? value : `${value as string}`; + validateHeaderValue("Set-Cookie", text); + values.push(text); + } catch (error: unknown) { + this.cookies.delete(cookie); + report(error); + } + } + return values; + } + + /** + * Runs a call that may commit the head of the response (`writeHead()`, a + * `write()`, which counts the bytes of its chunk first for + * `strictContentLength`, or an `end()`, which also sets the length of its + * chunk): when Node.js refuses the head (e.g. a `Trailer` of a body that is + * not chunked, once it has decided to close the connection), the framing + * state of the response is the one from before the call (see + * `IFramingState`), so that the reply that follows (e.g. of an error) is + * framed as if the refused head had never been attempted + */ + #restoring(fn: () => T): T { + if (this.headersSent) { + return fn(); + } + const state = this[kSaveFraming](); + try { + return fn(); + } catch (error: unknown) { + // Not when it has committed the head (e.g. a failure of a later write) + if (!this.#committed()) { + this[kRestoreFraming](state); + } + throw error; + } + } + + /** Whether the head of the response has been committed (see `headersSent`) */ + #committed(): boolean { + return this.headersSent; + } + + /** + * Saves the framing state of the response (see `IFramingState`) + * + * @internal + */ + public [kSaveFraming](): IFramingState { + return { + status: this.#statusCode, + reason: this.#statusMessage, + fields: [...framingFields, ...framingCounters].map((key) => [ + key, + Reflect.get(this, key), + ]), + headers: (this as unknown as { getRawHeaderNames(): string[] }) + .getRawHeaderNames() + .map((name) => [ + name, + this.getHeader(name) as number | string | string[], + ]), + generation: this.#framing, + }; + } + + /** + * Restores a framing state saved by `[kSaveFraming]()`, with the rights of + * the framework: the headers, then the fields (removing a header sets some + * of them), the status and the reason phrase + * + * @internal + */ + public [kRestoreFraming](state: IFramingState): void { + // What reading the state throws is formatted (see `formatStack()`) + try { + this[kFramework](() => { + // Set again only when they have changed: `setHeader()` of Node.js + // converts a value of another type as it checks it (e.g. a + // `Content-Length` that binden has refused, see `kValidateHead`) + if (!this.#hasHeaders(state.headers)) { + for (const name of this.getHeaderNames()) { + super.removeHeader(name); + } + for (const [name, value] of state.headers) { + super.setHeader(name, BindenResponse.#copy(value)); + } + } + for (const [name, value] of state.fields) { + Reflect.set(this, name, value); + } + }); + this.#statusCode = state.status; + this.#statusMessage = state.reason; + this.#framing = state.generation; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Whether the response has these headers (see `IFramingState`): the same + * names, in the same order and case, and the same values (each element of + * an array) + */ + #hasHeaders(headers: IFramingState["headers"]): boolean { + const names = ( + this as unknown as { getRawHeaderNames(): string[] } + ).getRawHeaderNames(); + return ( + names.length === headers.length && + headers.every(([name, value], index) => { + const current: unknown = super.getHeader(name); + return ( + names[index] === name && + (Array.isArray(value) + ? Array.isArray(current) && + current.length === value.length && + value.every((element, at) => current[at] === element) + : current === value) + ); + }) + ); + } + + /** + * Checks the arguments of `writeHead()` as Node.js does, without changing + * the response (see `kValidateHead`), and returns them as Node.js gets them + * (see `IHead`): each is read once, and converted no more than Node.js + * converts it. The status code is converted once, as Node.js converts it, + * and refused as Node.js refuses it (`ERR_HTTP_INVALID_STATUS_CODE`, the + * value given in its message, as Node.js formats it). Then the reason + * phrase that it would send (the one given, or else the one of the + * response), and the names and the values of the headers given (a list must + * be of pairs), as copies (see `headersOf()`): a name that is not a string, + * and a value of another type (see `inert()`), which Node.js converts as it + * checks them, are left to Node.js. The `Content-Length` values that the head would have (the ones given, or + * else the ones set) must each be a number or a string, checked before + * anything converts them (also a hole of an array, see `lengthValues()`), + * and be neither a list nor disagree (see `lengthRefusal()`): every path + * that commits the head runs `writeHead()` (also a first `write()`, an + * `end()` and `flushHeaders()`), which then throws + * `ERR_HTTP_INVALID_HEADER_VALUE` before anything is committed. + * + * @internal + */ + public [kValidateHead](...args: unknown[]): IHead { + // What it throws is formatted (see `formatStack()`), e.g. the errors of + // Node.js for a header that it refuses + try { + return this.#validateHead(args); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * A status code converted once, as `writeHead()` of Node.js converts it + * (`| 0`), and refused out of range as Node.js refuses it + * (`ERR_HTTP_INVALID_STATUS_CODE`, with the value given in its message, as + * Node.js formats it) + */ + static #statusOf(statusCode: unknown): number { + const code = (statusCode as number) | 0; + if (code < 100 || code > 999) { + throw Object.assign( + formatStack( + new RangeError(format("Invalid status code: %s", statusCode)), + ), + { code: "ERR_HTTP_INVALID_STATUS_CODE" }, + ); + } + return code; + } + + /** + * The status code that binden decides by before the head is committed by + * a send (see `#bodiless()`), or by a write (see `#hasBody()`, once + * `#converted()`): the integer that Node.js commits (it converts the status + * by `| 0`, then sets that number). One that is not a number (e.g. an + * object or a string assigned to `statusCode`) is converted once (see + * `#statusOf()`), and that number is the status of the response from then + * on: converted as often as by plain Node.js. One that its conversion + * refuses (or that throws as it is converted) is left as it is, and the + * call throws, before anything is changed. A number is truncated as + * Node.js truncates it, which leaves an integer as it is (e.g. `204.5` is a + * `204`, without a body nor its length): one in range is the status from + * then on, and one out of range is left as it is, which the commit of the + * head refuses, by the value given (see `#converted()`). + */ + #status(): number { + const status = this.#statusCode; + if (this.headersSent) { + return status; + } else if (typeof status === "number") { + const code = status | 0; + if (code >= 100 && code <= 999) { + this.#statusCode = code; + } + return code; + } + const code = BindenResponse.#statusOf(status); + this.#statusCode = code; + return code; + } + + /** + * Converts the status of the response once for a call that commits the + * head by it (a first `write()`, also of a response without a body, e.g. to + * `HEAD`, an `end()` with or without a chunk, and `writeHead()` of that + * status, by which Node.js commits `flushHeaders()`): the integer that + * Node.js commits is stored before anything else is checked (e.g. the + * encoding of a chunk, or the reason phrase) and before the framing is + * saved (see `#restoring()`, and `[kSaveFraming]()` of a library), as + * Node.js stores it before it refuses a reason phrase. A refused attempt + * then restores that number, which another attempt does not convert again + * (e.g. `204.5`, an object or a string are then `204`). A status that + * Node.js refuses (one out of range once truncated, e.g. `1000`, or whose + * conversion throws) throws as Node.js throws it + * (`ERR_HTTP_INVALID_STATUS_CODE`, by the value given, see `#statusOf()`), + * and is left as it is. + */ + #converted(): void { + this.#statusCode = BindenResponse.#statusOf(this.#statusCode); + } + + /** `[kValidateHead]()`, which may throw an error that is not formatted */ + #validateHead(args: readonly unknown[]): IHead { + if (this.headersSent) { + // As `writeHead()` of Node.js, before it reads anything + throw BindenResponse.#headersSent(); + } + const [statusCode, reason, headers] = args; + // Converted once, as Node.js converts it (which gets the number) + const code = BindenResponse.#statusOf(statusCode); + const message = typeof reason === "string" ? reason : this.statusMessage; + if (typeof message === "string" && invalidReason.test(message)) { + throw Object.assign( + formatStack(new TypeError("Invalid character in statusMessage")), + { + code: "ERR_INVALID_CHAR", + }, + ); + } + // As `writeHead()` of Node.js: the headers follow a reason phrase, or are + // the second argument. Read once: Node.js gets the copies + const given = headersOf( + typeof reason === "string" ? headers : (headers ?? reason), + ); + const pairs: unknown[] = Array.isArray(given) + ? Array.isArray(given[0]) + ? given.flat() + : given + : Object.entries(given ?? {}).flat(); + if (pairs.length % 2 !== 0) { + throw Object.assign( + formatStack(new TypeError("The argument 'headers' is invalid")), + { + code: "ERR_INVALID_ARG_VALUE", + }, + ); + } + /** The `Content-Length` values given, which replace the ones set */ + let lengths: (number | string)[] | null = null; + for (let index = 0; index < pairs.length; index += 2) { + const name = pairs[index]; + const value = pairs[index + 1]; + // Node.js skips an empty name (of the headers that it sets), and + // refuses one that is not a string itself (converting it as it + // describes it) + if (typeof name === "string" && name !== "") { + validateHeaderName(name); + if (name.toLowerCase() === "content-length") { + // Each a number or a string, before anything converts it (see + // `lengthValues()`): also a value that is not an array (e.g. + // `undefined`), and a hole of an array + (lengths ??= []).push( + ...lengthValues(Array.isArray(value) ? value : [value]), + ); + } + // Also an array (of the values of the header) or a number, but + // never converted (see `inert()`) + if (inert(value)) { + validateHeaderValue(name, value as string); + } + } + } + // Each a number or a string (see `lengthValues()`), never converted + const refusal = lengthRefusal( + lengths ?? lengthValues(this.getHeader("Content-Length")), + ); + if (refusal !== null) { + throw Object.assign(formatStack(new TypeError(refusal)), { + code: "ERR_HTTP_INVALID_HEADER_VALUE", + }); + } + return [ + code, + typeof reason === "string" ? reason : null, + given as IHead[2], + ]; + } + + /** + * `writeHead()` by its deprecated alias (DEP0063), which Node.js 24 still + * has: on every version, it is `writeHead()` of the response, with its + * cookies and its guard once the response is sealed (the alias of Node.js + * would send the headers past both) + */ + public writeHeader( + statusCode: number, + statusMessage?: string, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this; + /** `writeHead()` without a reason phrase, by its deprecated alias */ + public writeHeader( + statusCode: number, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this; + public writeHeader(...args: unknown[]): this { + // The method of the response itself (e.g. of a compression, or of a + // subclass): what it throws is formatted (see `formatStack()`) + try { + return this.writeHead(...(args as [number, string])); + } catch (error: unknown) { + throw formatStack(error as 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)]; + } + + /** + * Writes a chunk of the body (see `write()` of Node.js). Once the response + * is sealed, the abandoned work writes nothing: its callback is called + * (asynchronously, without an error) and it returns `true`. + */ + public override write( + chunk: unknown, + callback?: (error: Error | null | undefined) => void, + ): boolean; + /** Writes a chunk of the body in an encoding (as the first signature) */ + public override write( + chunk: unknown, + encoding: BufferEncoding, + callback?: (error: Error | null | undefined) => void, + ): boolean; + public override write(...args: unknown[]): boolean { + // What it throws is formatted (see `formatStack()`), e.g. the errors of + // Node.js for a chunk that it refuses, or what the `toString()` of a + // cookie throws as the head is committed: inline, a hot path + try { + if (!this.#allows()) { + BindenResponse.#ignore(args); + return true; + } + const [chunk] = args; + if (typeof chunk !== "string" && !isUint8Array(chunk)) { + // Refused by Node.js before anything else (e.g. `null`), whatever the + // state of the response + return super.write(...(args as [unknown])); + } else if (!this.headersSent && !this.destroyed) { + // Node.js commits the head by it (see `#converted()`): not once the + // response is destroyed (which an end that has not committed it has + // also finished) + this.#converted(); + } + const written = this.#normalized(args, false); + this.#beyond(written); + // Node.js counts the bytes of its chunk before it commits the head (see + // `#restoring()`) + return this.#stopping(() => + this.#restoring(() => { + this.#ordered(written, false); + return super.write(...(callbacksOf(written) as [unknown])); + }), + ); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Refuses the chunk of a `write()` past the `Content-Length` of a body + * under `strictContentLength` while the head is not generated, with the + * error of Node.js (`ERR_HTTP_CONTENT_LENGTH_MISMATCH`), before Node.js + * gets it: Node.js reads the length from the head that it generates, so + * that it checks only the chunks that follow it, and would write all of + * the first one, whose excess the client would read as the next response + * of the connection. Nothing is committed: the response stays usable (e.g. + * for the reply of the error). The chunk is checked as Node.js checks the + * others (by a message of its own, whose head cannot be generated): in its + * bytes, for a valid `Content-Length` of a body (not to `HEAD`, nor with a + * 204 or a 304 status) that is not chunked. + */ + #beyond(args: readonly unknown[]): void { + const [chunk] = args; + if ( + this.strictContentLength && + !this.#committed() && + this.#writes(chunk, false) && + !this.hasHeader("Transfer-Encoding") + ) { + const message = new OutgoingMessage(); + Reflect.set(message, "strictContentLength", true); + // Not checked without a valid length (`null`) + Reflect.set(message, "_contentLength", this.#length()); + const encoding = BindenResponse.#encodingOf(args); + try { + message.write(chunk, (encoding ?? "utf8") as BufferEncoding); + } catch (error: unknown) { + // Within the length, the message cannot generate its head + if (property(error, "code") === "ERR_HTTP_CONTENT_LENGTH_MISMATCH") { + throw error; + } + } + } + } + + /** + * Runs a `write()` or an `end()` of Node.js, whose chunk and encoding have + * been checked (a chunk that is not one, or an unknown encoding, throws + * before anything is changed, see `#normalized()`): when it throws once the + * head is committed (before the call, or by it), nothing more of the + * response is sent. Node.js may have written the head and the chunk into + * the socket that it has corked before it throws (e.g. a body longer or + * shorter than the `Content-Length` that `strictContentLength` checks): + * flushing them would send a head that the body contradicts, whose excess + * the client would read as the next response of the connection. The + * response is destroyed at once, with the rights of the framework, before + * the error is thrown, so that neither the cork of `end()` nor the one that + * Node.js releases on the next tick flushes them, and a response that waits + * for the one before it is cut once its socket comes (see `destroy()`). The + * bytes of an earlier write that has been flushed are not recalled. An error + * that leaves what is queued valid (see `keeping`, e.g. + * `ERR_HTTP_BODY_NOT_ALLOWED`, which Node.js throws for a body written to + * `HEAD` once it has generated the head, and queued nothing more) leaves the + * response usable, as Node.js does: an `end()` that follows sends a + * complete reply. + */ + #stopping(fn: () => T): T { + try { + return fn(); + } catch (error: unknown) { + if (this.#committed() && !keeping.has(property(error, "code"))) { + this[kFramework](() => this.destroy()); + } + throw error; + } + } + + /** + * Whether Node.js writes the chunk of a write (or of an `end()`) of the + * body. It ignores a falsy chunk of `end()`, the chunks of a response that + * has ended or is destroyed (their callback gets the error), and of one + * without a body (e.g. to `HEAD`); it refuses a chunk that is neither a + * string nor bytes (e.g. `null`) before it changes anything. + */ + #writes(chunk: unknown, end: boolean): boolean { + return !( + (end && !truthy(chunk)) || + Reflect.get(this, "finished") === true || + this.destroyed || + (typeof chunk !== "string" && !isUint8Array(chunk)) || + !this.#hasBody() + ); + } + + /** + * The encoding of a write (or of an `end()`), as Node.js reads it: a falsy + * one (e.g. `undefined`) or a callback in its place is none (`null`) + */ + static #encodingOf(args: readonly unknown[]): unknown { + const [, second] = args; + return typeof second === "function" || !truthy(second) ? null : second; + } + + /** + * The arguments of a write (or of an `end()`) of the body, once checked: + * the chunk that Node.js writes is checked as Node.js checks it, but before + * anything is sent, so that an unknown encoding throws + * `ERR_UNKNOWN_ENCODING`, which Node.js throws only once the head is + * committed (or, for a response that waits for the one before it, as it + * flushes it later, out of any call: the process crashes). + * + * A string in an encoding whose length Node.js only estimates (`hex`, + * `base64` and `base64url`, in any case) is then the bytes that + * `Buffer.from()` decodes, as on Node.js 26 (e.g. without the last digit of + * an odd number of hex digits): Node.js would frame the body by the + * estimate (e.g. one byte for the invalid `"zz"` in `hex`) but write fewer + * bytes, so that the client would read the next response of the connection + * as the rest of the body, and Node.js 24 aborts the process when it writes + * an odd number of hex digits to the socket. An explicit `Content-Length` + * is never changed: `strictContentLength` checks it against these bytes. + */ + #normalized(args: readonly unknown[], end: boolean): unknown[] { + const [chunk, ...rest] = args; + const encoding = BindenResponse.#encodingOf(args); + if ( + this.#writes(chunk, end) && + encoding !== null && + !( + typeof encoding === "string" && + (Buffer.isEncoding(encoding) || + (typeof chunk !== "string" && encoding === "buffer")) + ) + ) { + throw Object.assign( + formatStack(new TypeError(`Unknown encoding: ${described(encoding)}`)), + { code: "ERR_UNKNOWN_ENCODING" }, + ); + } else if ( + typeof chunk === "string" && + chunk !== "" && + typeof encoding === "string" && + estimated.has(encoding.toLowerCase()) + ) { + return [Buffer.from(chunk, encoding as BufferEncoding), ...rest]; + } + return [...args]; + } + + /** + * Keeps the order of a chunk (checked, see `#normalized()`) that would + * commit the head while output is queued before it (e.g. a `103 Early + * Hints` of a pipelined request, which waits for the response before it): + * Node.js puts the head before what is queued when the chunk is bytes, or a + * string in an encoding other than exactly `utf8`, `latin1` or none (e.g. + * `utf-8`, `ucs2`), so that the client would read the informational + * response as the body. The head is then committed by an empty `latin1` + * write (which Node.js queues in order), framed as Node.js frames that + * chunk (the only chunk of `end()` gives the `Content-Length` of a body + * without one), and the chunk is written as it is. + */ + #ordered(args: readonly unknown[], end: boolean): void { + const [chunk] = args; + const encoding = BindenResponse.#encodingOf(args); + if ( + !this.#writes(chunk, end) || + // The state of the send queue of Node.js: the head has been queued, or + // no output has + Reflect.get(this, "_headerSent") === true || + Reflect.get(this, "outputSize") === 0 || + // Queued after the head by Node.js itself + (typeof chunk === "string" && + (encoding === null || encoding === "utf8" || encoding === "latin1")) + ) { + return; + } + if (end && typeof Reflect.get(this, "_header") !== "string") { + // As `end()` of Node.js: a body of one chunk has its length + Reflect.set( + this, + "_contentLength", + typeof chunk === "string" + ? Buffer.byteLength(chunk, encoding as BufferEncoding) + : (chunk as Uint8Array).byteLength, + ); + } + super.write("", "latin1"); + } + + /** + * Whether Node.js writes the body of the response: not to `HEAD`, and not + * with a 1xx, 204 or 304 status (which `writeHead()` applies) + */ + #hasBody(): boolean { + return ( + Reflect.get(this, "_hasBody") === true && + (Boolean(Reflect.get(this, "_header")) || + // Converted once (see `#status()`) + BindenResponse.#withBody(this.#status())) + ); + } + + /** Whether Node.js writes the body of a response of a status: not 1xx, 204 nor 304 */ + static #withBody(status: number): boolean { + return !( + BindenResponse.#informational(status) || + status === 204 || + status === 304 + ); + } + + /** + * Ends the response (see `end()` of Node.js). Once the response is sealed, + * the abandoned work does not end it: its callback is called + * (asynchronously, without an error). + */ + public override end(callback?: () => void): this; + /** Ends the response with a last chunk (as the first signature) */ + public override end(chunk: unknown, callback?: () => void): this; + /** Ends the response with a last chunk in an encoding (as the first signature) */ + public override end( + chunk: unknown, + encoding: BufferEncoding, + callback?: () => void, + ): this; + public override end(...args: unknown[]): this { + // What it throws is formatted (see `formatStack()`), as for `write()`: + // inline, a hot path + try { + if (!this.#allows()) { + BindenResponse.#ignore(args); + return this; + } + const [first] = args; + this.#chunkOfEnd(first); + if ( + !this.headersSent && + // Also once the response is destroyed, unless the end has a chunk + // (not a callback): Node.js ignores it then, and finishes the + // response without its head + Reflect.get(this, "finished") !== true && + (typeof first === "function" || !truthy(first) || !this.destroyed) + ) { + // Node.js commits the head by it (see `#converted()`) + this.#converted(); + } + const ended = this.#normalized(args, true); + // Node.js frames the body of its chunk before it commits the head (see + // `#restoring()`) + return this.#stopping(() => + this.#restoring(() => { + this.#ordered(ended, true); + return this.#uncorking(() => + super.end(...(callbacksOf(ended) as [])), + ); + }), + ); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Refuses the chunk of an `end()` that Node.js refuses (truthy, but neither + * a string nor bytes) with the error of Node.js, before its `end()` corks + * the socket: its `write()` checks the chunk before anything else. Not once + * the response has ended, where Node.js emits `ERR_STREAM_WRITE_AFTER_END`. + */ + #chunkOfEnd(chunk: unknown): void { + if ( + truthy(chunk) && + typeof chunk !== "function" && + typeof chunk !== "string" && + !isUint8Array(chunk) && + Reflect.get(this, "finished") !== true + ) { + super.write(chunk); + } + } + + /** + * Runs `end()` of Node.js, which corks the socket before it frames the body + * (and uncorks it once it has ended the response): when it throws for a + * head that Node.js refuses (which stays uncommitted), the socket is + * uncorked back to what it was, so that the reply that follows is not held + * (e.g. a `flush()` would never resolve, and a body larger than the socket + * buffers would wait for a `drain` that never comes). Never once the head + * is committed: what the socket holds is not sent (see `#stopping()`). + */ + #uncorking(fn: () => T): T { + const { socket } = this; + const corked = socket?.writableCorked ?? 0; + try { + return fn(); + } catch (error: unknown) { + if (socket !== null && !this.#committed()) { + while (socket.writableCorked > corked) { + socket.uncork(); + } + } + throw error; + } + } + + /** + * Sends the headers (see `flushHeaders()` of Node.js), unless the response + * is sealed: what it throws is formatted (see `formatStack()`), e.g. the + * error of Node.js for a head that it refuses + */ + public override flushHeaders(): void { + try { + if (this.#allows()) { + super.flushHeaders(); + } + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Sets a header (see `setHeader()` of Node.js), unless the response is + * sealed. An array of values is copied: Node.js would keep the array itself, + * which the work that set it could change later, also once it may no longer + * change the response (as `appendHeader()` and `setHeaders()`, which set + * their values through it). + */ + public override setHeader( + name: string, + value: number | string | readonly string[], + ): this { + // What it throws is formatted (see `formatStack()`), e.g. the error of + // Node.js for a name or a value that it refuses: inline, a hot path + try { + if (this.#allows()) { + super.setHeader(name, BindenResponse.#copy(value)); + this.#reframed(name); + } + return this; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Advances the generation of the framing (see `#framing`) when a header + * that frames the body has changed: once Node.js has accepted the change (a + * name or a value that it refuses changes nothing), also to the same value + */ + #reframed(name: string): void { + if (framing.has(String(name).toLowerCase())) { + this.#framing += 1; + } + } + + /** + * A header (see `getHeader()` of Node.js): an array of values is a copy, so + * that changing it does not change the response + */ + public override getHeader( + name: string, + ): number | string | string[] | undefined { + // What it throws is formatted (see `formatStack()`): inline, a hot path + try { + return BindenResponse.#copy(super.getHeader(name)); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * The headers (see `getHeaders()` of Node.js): the arrays of values are + * copies, so that changing them does not change the response + */ + public override getHeaders(): OutgoingHttpHeaders { + const headers = super.getHeaders(); + for (const [name, value] of Object.entries(headers)) { + headers[name] = BindenResponse.#copy(value); + } + return headers; + } + + /** A copy of an array (of the values of a header), or the value itself */ + static #copy(value: T): T { + return Array.isArray(value) ? ([...(value as unknown[])] as T) : value; + } + + /** + * Appends a header (see `appendHeader()` of Node.js), unless the response + * is sealed (what it throws is formatted, see `formatStack()`) + */ + public override appendHeader( + name: string, + value: string | readonly string[], + ): this { + try { + if (this.#allows()) { + super.appendHeader(name, value); + this.#reframed(name); + } + return this; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Sets headers (see `setHeaders()` of Node.js), unless the response is + * sealed (what it throws is formatted, see `formatStack()`) + */ + public override setHeaders( + headers: Headers | Map, + ): this { + try { + return this.#allows() ? super.setHeaders(headers) : this; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Sets the timeout of the socket (see `setTimeout()` of Node.js), unless the + * response is sealed: the abandoned work cannot cut the reply of the + * framework by the timeout of its connection (what it throws is formatted, + * see `formatStack()`) + */ + public override setTimeout(msecs: number, callback?: () => void): this { + try { + return this.#allows() ? super.setTimeout(msecs, callback) : this; + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Removes a header (see `removeHeader()` of Node.js), unless the response + * is sealed (what it throws is formatted, see `formatStack()`) + */ + public override removeHeader(name: string): void { + try { + if (this.#allows()) { + super.removeHeader(name); + this.#reframed(name); + } + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Adds trailers (see `addTrailers()` of Node.js), unless the response is + * sealed (what it throws is formatted, see `formatStack()`) + */ + public override addTrailers( + headers: OutgoingHttpHeaders | readonly [string, string][], + ): void { + try { + if (this.#allows()) { + super.addTrailers( + trailersOf(headers) as OutgoingHttpHeaders | [string, string][], + ); + } + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Destroys the response and its connection (see `destroy()` of Node.js), + * unless the response is sealed: the abandoned work cannot cut the reply + * of the framework. With an error while an adopted pipe writes (see + * `#adopt()`), it is the `data` listener of the pipe that catches a write + * that throws (e.g. for a head that Node.js refuses): the pipe fails + * instead (see `#pipeFailed()`), so that its work gets the error. So does + * a destroy by an error handler that its pipe guards (see `#guarded()`), + * with its error (or `ERR_STREAM_PREMATURE_CLOSE`), and a destroy of no + * work while it replies (see `#replier()`). A response that the framework + * destroys before it has a socket is cut once it gets one (see + * `#cutQueued()`). + */ + public override destroy(error?: Error): this { + // What it throws is formatted (see `formatStack()`), e.g. what Node.js + // throws as it reads an error that is a revoked proxy + try { + return this.#destroy(error); + } catch (thrown: unknown) { + throw formatStack(thrown as Error); + } + } + + /** `destroy()`, which may throw an error that is not formatted */ + #destroy(error?: Error): this { + const marker = this.#marker; + const owner = owners.getStore() ?? this.#replier(); + if ( + marker !== null && + typeof error !== "undefined" && + this.#pipeFailed(marker.owner, error, marker.source) + ) { + return this; + } else if ( + this.#guarded(owner) && + this.#pipeFailed(owner, error ?? BindenResponse.#premature(), null) + ) { + return this; + } else if (!this.#allows()) { + return this; + } + this.#cutQueued(); + return super.destroy(error); + } + + /** + * The framework destroys a response that has not ended: when it waits for + * the one before it on its connection (no socket yet, e.g. started at its + * deadline), its socket is destroyed once Node.js assigns it, before + * Node.js flushes what the response has queued (see `emit()`). Node.js + * 24.14.1 does so itself, but later versions would send its head and the + * part of its body that it has written, then keep the connection open for + * a response that never ends. The responses before it have finished by + * then: Node.js assigns the socket once the one before has finished. A + * complete reply is not cut (Node.js sends it), nor one that the + * application destroys (as without binden). + */ + #cutQueued(): void { + if (forced(this) && !this.writableEnded) { + this.#cut = true; + } + } + + /** + * Node.js assigns its socket to the response (once the response before it + * has finished): the socket of a response that is cut is destroyed (see + * `#cutQueued()`) + */ + #assigned(socket: Duplex): void { + if (this.#cut) { + socket.destroy(); + } + } + + /** + * Whether a `destroy()` of the current work is a failure of its pipe: the + * work of an error handler (see `IOwnership.guards()`) that may mutate + * the response, with a pipe of its own that is adopted now, before the + * headers are committed (e.g. `pipeline(head, transform, response)`, whose + * callbacks run as the handler, destroys the response for a failure of its + * head). Never a destroy of the framework, nor of a response that the + * transport has closed (e.g. the client has gone away). + */ + #guarded(owner: object | null | undefined): owner is object { + return ( + typeof owner === "object" && + owner !== null && + !this.headersSent && + !this.destroyed && + !forced(this) && + this.#ownership.guards(owner) && + this.#allows(owner) && + (this.#sources.get(owner)?.size ?? 0) > 0 + ); + } + + /** + * The work that a destroy of no work stands for (see `destroy()`): the + * work that replies now (see `IOwnership.replier()`), while the connection + * of the response is alive (`null` otherwise, e.g. the client has gone + * away). E.g. `pipeline(head, transform, response)` of an error handler + * destroys the response as the work that has emitted the failure of its + * head (a timer of no request): it is a failure of the pipe of the error + * handler (see `#guarded()`). + */ + #replier(): object | null { + const { socket } = this.req as { socket: { destroyed?: unknown } | null }; + return socket?.destroyed === true ? null : this.#ownership.replier(); + } + + /** + * Sends `100 Continue`, unless the response is sealed or the request is + * HTTP/1.0 (its callback is then called asynchronously). Once the head is + * committed, it throws `ERR_HTTP_HEADERS_SENT` (see `#informs()`). + */ + public override writeContinue(callback?: () => void): void { + // Node.js takes anything as its callback: only the refusal of a + // committed head is formatted (see `#informs()`) + if (!this.#informs()) { + BindenResponse.#ignore([callback]); + return; + } + super.writeContinue(...(callbacksOf([callback]) as [])); + } + + /** + * Sends `102 Processing`, unless the response is sealed or the request is + * HTTP/1.0 (its callback is then called asynchronously). Once the head is + * committed, it throws `ERR_HTTP_HEADERS_SENT` (see `#informs()`). + */ + public override writeProcessing(callback?: () => void): void { + // Node.js takes anything as its callback: only the refusal of a + // committed head is formatted (see `#informs()`) + if (!this.#informs()) { + BindenResponse.#ignore([callback]); + return; + } + super.writeProcessing(...(callbacksOf([callback]) as [])); + } + + /** + * Sends `103 Early Hints`, unless the response is sealed or the request is + * HTTP/1.0 (its callback is then called asynchronously). Once the head is + * committed, it throws `ERR_HTTP_HEADERS_SENT` before it reads the hints + * (see `#informs()`). + */ + public override writeEarlyHints( + hints: Record, + callback?: () => void, + ): void { + try { + if (!this.#informs()) { + BindenResponse.#ignore([callback]); + return; + } + super.writeEarlyHints( + hintsOf(hints) as Record, + ...(callbacksOf([callback]) as []), + ); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Sends an informational response (see `writeInformation()` of Node.js, + * from 24.18.0), unless the response is sealed or the request is HTTP/1.0 + * (its callback is then called asynchronously). Once the head is committed, + * it throws `ERR_HTTP_HEADERS_SENT` (see `#informs()`). Without the method + * of Node.js (before 24.18.0), it throws a `TypeError` of binden. + */ + public override writeInformation( + statusCode: number, + headers?: OutgoingHttpHeaders | readonly string[], + callback?: () => void, + ): void; + /** + * Sends an informational response without headers (as the first signature, + * which Node.js has: binden gives it no headers) + */ + public override writeInformation( + statusCode: number, + callback: () => void, + ): void; + public override writeInformation(...args: unknown[]): void { + try { + // Of Node.js from 24.18.0, read as it is called + const informational: unknown = Reflect.get(parent, "writeInformation"); + // Also once the head is committed, on every version (see `#informs()`) + if (!this.#informs()) { + BindenResponse.#ignore(args); + return; + } else if (typeof informational !== "function") { + throw formatStack( + new TypeError( + "writeInformation() is not supported by this version of Node.js (24.18.0 or later)", + ), + ); + } + // The callback in place of the headers, which Node.js does not take: + // no headers + const [statusCode, second] = args; + const given = + args.length === 2 && typeof second === "function" + ? [statusCode, null, second] + : args; + Reflect.apply(informational, this, callbacksOf(given)); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Whether an informational response may be sent: not once the response is + * sealed, and never to an HTTP/1.0 client, which does not know them + * (RFC 9110, section 15.2). Otherwise, once the head of the response is + * committed, it throws `ERR_HTTP_HEADERS_SENT`, formatted (see + * `formatStack()`), as Node.js 26 does: before 24.18.0, Node.js would write + * the informational head into the body, which the client would read as part + * of it (or, past its `Content-Length`, as the next response of the + * connection). + */ + #informs(): boolean { + // Of the request that the response has been made for, which may be the + // application's own (e.g. a proxy whose getters throw): what reading it + // throws is formatted (see `formatting()`) + const old = formatting(() => { + const { httpVersionMajor = 1, httpVersionMinor = 1 } = this.req as { + httpVersionMajor?: number; + httpVersionMinor?: number; + }; + return ( + httpVersionMajor < 1 || (httpVersionMajor === 1 && httpVersionMinor < 1) + ); + }); + if (old || !this.#allows()) { + return false; + } else if (this.headersSent) { + throw BindenResponse.#headersSent(); + } + return true; + } + + /** The error of Node.js for a head written once it is committed, formatted (see `formatStack()`) */ + static #headersSent(): Error { + return Object.assign( + formatStack( + new Error("Cannot write headers after they are sent to the client"), + ), + { code: "ERR_HTTP_HEADERS_SENT" }, + ); + } + + /** Calls the callback (if any) of an ignored call once, asynchronously and without an error */ + static #ignore(args: readonly unknown[]): void { + const callback = args.find((arg) => typeof arg === "function") as + (() => void) | undefined; + if (callback) { + process.nextTick(callback); + } + } + + /** + * Seals the response: from now on, only the work of the owners that + * `allows(owner)` accepts (none without it, or when it throws) mutates it. + * The other calls are ignored: the sending methods resolve, `write()` and + * `end()` call their callback, the setters do nothing. + * + * @internal + */ + public [kSeal](allows: (owner: object | undefined) => boolean = never): void { + this.#seals.push(allows); + } + + /** + * Sets how the framework handles the works that own the response (see + * `IOwnership`) + * + * @internal + */ + public [kOwnership](ownership: IOwnership): void { + this.#ownership = ownership; + } + + /** + * Works may no longer mutate the response (see `kRevoke`): they are told so + * on the next tick, after what they do now (e.g. the listeners that + * `pipeline()` adds once it has piped), with the failure of a pipe of one + * of them (see `#deliver()`) + * + * @internal + */ + public [kRevoke]( + revoked: readonly object[], + failure: IPipeFailure | null, + ): Promise { + // Read once, at once: what reading them throws is formatted (see + // `formatting()`), never thrown on the next tick + const [works, failed] = formatting( + () => + [ + [...revoked], + failure === null + ? null + : { owner: failure.owner, error: failure.error }, + ] as const, + ); + for (const owner of works) { + this.#revoked.set(owner, false); + } + return new Promise((resolve) => { + process.nextTick(() => { + for (const owner of works) { + this.#revoked.set(owner, true); + // Without a failure, also for a work that is `undefined` + this.#deliver( + owner, + failed !== null && failed.owner === owner ? [failed.error] : null, + ); + } + resolve(); + }); + }); + } + + /** + * Fails a pipe of a work of the exchange of the response (see + * `kPipeFailure`) + * + * @internal + */ + public [kPipeFailure]( + owner: object, + error: unknown, + source: EventEmitter, + ): boolean { + return this.#pipeFailed(owner, error, source); + } + + /** + * Adds an observer of the framework (see `kListen`), once by default: + * returns what removes it + * + * @internal + */ + public [kListen]( + event: string, + listener: (...args: never[]) => unknown, + options: { + /** Whether it runs once (`true`), and is removed then */ + readonly once?: boolean | undefined; + } = {}, + ): () => void { + // Checked, and read once, before anything is changed: never a value that + // would throw as its event is emitted (see `formatting()`) + const given: unknown = listener; + if (typeof given !== "function") { + throw formatStack( + new TypeError(`The listener is not a function: ${described(given)}`), + ); + } + const { once = true } = formatting(() => ({ once: options.once })); + const observer: IObserver = { + listener: listener as IListener, + owner: owners.getStore(), + once: Boolean(once), + fired: false, + }; + const observers = this.#observers.get(event) ?? []; + observers.push(observer); + this.#observers.set(event, observers); + return (): void => { + this.#unobserve(event, observer); + }; + } + + /** + * Removes the last observer of the framework of a listener (see + * `kUnlisten`) + * + * @internal + */ + public [kUnlisten]( + event: string, + listener: (...args: never[]) => unknown, + ): void { + const observer = this.#observers + .get(event) + ?.findLast((candidate) => candidate.listener === listener); + if (typeof observer !== "undefined") { + this.#unobserve(event, observer); + } + } + + /** Removes an observer (see `[kListen]()`), if it is still there */ + #unobserve(event: string | symbol, observer: IObserver): void { + const observers = this.#observers.get(event) ?? []; + const index = observers.indexOf(observer); + if (index !== -1) { + observers.splice(index, 1); + if (observers.length === 0) { + this.#observers.delete(event); + } + } + } + + /** + * Emits an event to the listeners of the response, then to the observers + * of the framework that it has then (see `[kListen]()`): also when a + * listener throws, which is thrown once they have run. An `error` that the + * response has no listener of, but observers, is not thrown (as when they + * were listeners): only its `errorMonitor` listeners get it before them, + * and one of them that throws is thrown once they have run as well. + */ + #emit(event: string | symbol, args: unknown[]): boolean { + const observers = this.#observers.get(event); + if (typeof observers === "undefined") { + return parent.emit.call(this, event, ...args); + } + const snapshot = [...observers]; + const monitored = + event === "error" && intrinsic.listenerCount.call(this, "error") === 0; + try { + parent.emit.call(this, monitored ? errorMonitor : event, ...args); + } catch (error: unknown) { + const failure = this.#observe(event, snapshot, args); + if (failure !== null) { + const message = "An observer of the response has failed"; + this.#ownership.debug(message, failure.error); + } + throw error; + } + const failure = this.#observe(event, snapshot, args); + if (failure !== null) { + throw failure.error; + } + return true; + } + + /** + * Runs the observers of an event (see `[kListen]()`), each as the work + * that has added it, with the response as its receiver, and once when it + * runs once (also when it emits its event again): what the first one that + * throws has thrown, once they have all run + */ + #observe( + event: string | symbol, + observers: readonly IObserver[], + args: unknown[], + ): { error: unknown } | null { + let failure: { error: unknown } | null = null; + for (const observer of observers) { + if (!observer.fired) { + if (observer.once) { + observer.fired = true; + this.#unobserve(event, observer); + } + try { + as(observer.owner, () => + Reflect.apply(observer.listener, this, args), + ); + } catch (error: unknown) { + failure ??= { error }; + } + } + } + return failure; + } + + /** + * Cancels the sends in progress (of a stream, or of a file), unless the + * headers have been committed: each rejects with the reason, its source is + * released, and the response is restored as it was before the earliest of + * them (its status and its headers), without the framing of their bodies + * (also one set before them, e.g. by the application) nor the + * representation chosen for them, also for a send that has settled once + * its cancellation was recorded (see `[kCancelling]()`), so that another + * reply can be sent at once + * + * @internal + */ + public [kCancel](reason: unknown): void { + if (!this.headersSent) { + // A stream piped by the work that is abandoned has written nothing, + // and writes nothing from now on: it no longer starts the response + this.#piped = false; + const release: (() => void)[] = []; + this[kFramework](() => { + // Restored first, the latest first (e.g. the send of the file of a + // `sendFile()`, then that `sendFile()`), so that the response is as + // it was before the earliest: a later send (e.g. a second + // `sendFile()`, once the first has set the headers of its file) never + // reinstates what an earlier one has set. Also a send that has + // settled once its cancellation was recorded (see `#settled()`) + const rollbacks = [...this.#rollbacks.values()].reverse(); + this.#rollbacks.clear(); + for (const rollback of rollbacks) { + rollback?.(); + } + if (rollbacks.length > 0) { + // Then the framing of the bodies that are not sent, also one that + // was set before the earliest of them (e.g. a `Content-Length` of + // the application before a `sendFile()`), which no body of the + // reply that replaces them has: once, as the representation chosen + // for them, which that reply chooses again + this.#remove("Content-Length", "Transfer-Encoding", "Content-Range"); + this.#forget(); + } + for (const cancel of [...this.#pending.values()]) { + cancel(reason, release); + } + }); + // Without the rights of the framework: a source is code of the + // application (e.g. its `_destroy()`), which may not change the response + // of the framework either + for (const fn of release) { + fn(); + } + } + } + + /** + * Records at once the cancellation of the sends in progress (of a stream, + * or of a file), unless the headers have been committed, which + * `[kCancel]()` carries out later: each rejects with the reason, however it + * settles in between (e.g. a `sendFile()` that sees that its work may no + * longer mutate the response, whose send would otherwise be ignored), and + * is restored by `[kCancel]()` all the same (see `#rollbacks`). The sends + * started afterwards are not cancelled. + * + * @internal + */ + public [kCancelling](reason: unknown): void { + if (!this.headersSent) { + for (const key of this.#pending.keys()) { + // Whatever it is (e.g. `null`), as `[kCancel]()` + this.#cancellations.set(key, { reason: reason as Error }); + } + } + } + + /** + * The promise of a send in progress (see `#pending`), by its key: once its + * cancellation has been recorded (see `[kCancelling]()`), it rejects with + * its reason, however the send settles + */ + #cancellable( + key: object, + executor: (resolve: () => void, reject: (error: Error) => void) => void, + ): Promise { + return new Promise((resolve, reject) => { + executor( + () => { + const cancellation = this.#cancellations.get(key); + if (typeof cancellation === "undefined") { + resolve(); + } else { + reject(cancellation.reason); + } + }, + (error) => { + const cancellation = this.#cancellations.get(key); + reject( + typeof cancellation === "undefined" ? error : cancellation.reason, + ); + }, + ); + }); + } + + /** + * A send in progress has settled (see `#pending`): it is no longer + * cancelled, nor restored, unless its cancellation has been recorded (see + * `[kCancelling]()`), whose rollback (see `#rollbacks`) `[kCancel]()` still + * runs, whatever the send has done in between (e.g. failed, or settled as + * ignored) + */ + #settled(key: object): void { + this.#pending.delete(key); + if (!this.#cancellations.has(key)) { + this.#rollbacks.delete(key); + } + } + + /** + * Runs a function of the framework with its rights, whoever owns the + * response (e.g. to destroy it once sealed) + * + * @internal + */ + public [kFramework](fn: () => T): T { + // What it throws is formatted (see `formatStack()`), e.g. for a value + // that is not a function + try { + return force(this, fn); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * Starts an operation of a library that writes to the response (e.g. a + * compressor): `null` when the current work may not mutate the response + * + * @internal + */ + public [kOperation](): IResponseOperation | null { + const owner = owners.getStore(); + return this.#allows(owner) ? new Operation(owner, this.#control()) : null; + } + + /** + * Reports that the write (or the `end()`) that it is called in is + * discarded by the library that has replaced the response (see + * `kDiscardWrite`): a send is not cut for the bytes of that write + * + * @internal + */ + public [kDiscardWrite](): void { + this.#discardedWrites += 1; + } + + /** An operation of the current work (which may mutate the response) */ + #operation(): Operation { + return new Operation(owners.getStore(), this.#control()); + } + + /** What the operations of the response need from it */ + #control(): IRights { + this.#rights ??= { + allows: (owner): boolean => this.#allows(owner), + force: (fn: () => T): T => this[kFramework](fn), + ended: (): boolean => this.writableEnded, + }; + return this.#rights; + } + + /** + * Whether the work of an owner (the current one by default) may mutate the + * response: every check of `[kSeal]()` accepts it, or the framework itself + * mutates it + */ + #allows(owner: object | undefined = owners.getStore()): boolean { + if (forced(this)) { + return true; + } + for (const allows of this.#seals) { + try { + if (!allows(owner)) { + return false; + } + } catch { + return false; + } + } + return true; + } + + /** + * Lets a library (e.g. `@binden/compression`) choose the representation of + * the body of the response (see `[kRepresentation]()`): an object whose + * `choose` and `forget` are functions, read once, and called on it later. + * Anything else is refused with a `TypeError` that describes it (see + * `described()`), before it is kept. What reading it throws (e.g. a revoked + * proxy) is formatted (see `formatting()`). + * + * @internal + */ + public [kNegotiator](negotiator: IRepresentationNegotiator): void { + const { choose, forget } = formatting(() => { + const { choose: chosen, forget: forgotten } = objectOf( + negotiator, + "negotiator", + ) as Partial>; + return { choose: chosen, forget: forgotten }; + }); + if (typeof choose !== "function") { + throw BindenResponse.#notFunction("negotiator.choose", choose); + } else if (typeof forget !== "function") { + throw BindenResponse.#notFunction("negotiator.forget", forget); + } + this.#negotiator = { + choose: (length: number | null): IRepresentation => + Reflect.apply(choose, negotiator, [length]) as IRepresentation, + forget: (): void => { + Reflect.apply(forget, negotiator, []); + }, + }; + } + + /** The refusal of a value that is not a function (`name`), which describes it (see `described()`) */ + static #notFunction(name: string, value: unknown): TypeError { + return formatStack( + new TypeError(`\`${name}\` is not a function: ${described(value)}`), + ); + } + + /** + * The representation of a body of `length` bytes (`null` when it is not + * known), chosen before the preconditions of the request are evaluated + * (RFC 9110, section 13.2.2): the body as it is, unless a library (see + * `[kNegotiator]()`) codes it or replaces the response. The choice sends no + * header: the commit of the headers applies it. The work that may no + * longer mutate the response gets the body as it is (nothing of it is sent). + * + * @internal + */ + public [kRepresentation](length: number | null): IRepresentation { + // What the negotiator throws is formatted (see `formatStack()`) + try { + return this.#negotiator === null || !this.#allows() + ? identity + : this.#negotiator.choose(length); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** + * The body that a representation has been chosen for is not sent (e.g. its + * stream has failed before its first chunk): the reply that replaces it + * gets a representation of its own. Not by the abandoned work, whose + * failure must not change the reply of the new owner. + */ + #forget(): void { + if (this.#allows()) { + // Where a send settles (e.g. in a listener of its source): what the + // negotiator throws is logged (`debug`), never thrown there + try { + this.#negotiator?.forget(); + } catch (error: unknown) { + this.#ownership.debug("The negotiator has failed to forget", error); + } + } + } + + /** + * Send a response: a string (or a number), bytes, a `Readable`, a + * stream-like source (see `IStreamLike`, e.g. of `readable-stream`), or no + * body. Any other object rejects with a `TypeError` before anything is + * sent, and so does a view of a detached `ArrayBuffer` (e.g. transferred), + * also as a chunk of a `Readable`, which is never written (the response is + * cut once it has sent anything). It resolves once the response is flushed + * to the connection. When the client goes away before, it rejects with the + * error of the connection when a pending write fails first (e.g. `EPIPE`, + * `ECONNRESET` or `ECANCELED`, whichever Node.js and the system report), + * otherwise as a premature close (`ERR_STREAM_PREMATURE_CLOSE`): a release + * of its source never decides it. + */ + public send( + data?: + Buffer | IStreamLike | Readable | Uint8Array | bigint | number | string, + ): Promise; + /** Send a string in an encoding (e.g. `latin1`) */ + public send(data: string, encoding: BufferEncoding): Promise; + public send(data?: unknown, encoding?: BufferEncoding): Promise { + try { + return this.#send(data, null, encoding); + } catch (error: unknown) { + // E.g. a reason phrase or a `Trailer` that Node.js refuses once it sends + // the headers: the response is unchanged, and the error can be replied + // (formatted, see `formatStack()`) + return Promise.reject(formatStack(error as Error)); + } + } + + /** + * Whether a send (also of `text()`, `html()`, `json()` and `form()`) is + * ignored: the response has ended, or the current work may no longer + * mutate it (see `[kSeal]()`) + */ + #ignored(): boolean { + return this.writableEnded || !this.#allows(); + } + + /** + * The status code of a send (see `#status()`): when its conversion throws, + * the source is released first (see `#release()`) + */ + #sending( + data: unknown, + kind: IStreamLikeMethods | "readable" | "value", + ): number { + try { + return this.#status(); + } catch (error: unknown) { + this.#release(data, kind); + throw error; + } + } + + /** + * `send()`, which may throw before it has changed the response. The + * `Content-Type` of `text()`, `html()`, `json()` and `form()` (`type`) is set + * once the data and the status are accepted: a refused call changes nothing + * of the response. + */ + #send( + data: unknown, + type: string | null, + encoding?: BufferEncoding, + ): Promise { + if (this.#ignored()) { + // Ignored: nothing of the data is read (e.g. a getter of an object of + // a work that may no longer mutate the response), but a Node.js + // `Readable` is released (see `#releaseIgnored()`) + this.#releaseIgnored(data); + return Promise.resolve(); + } + // What is sent (see `#kind()`): the methods of a stream-like source are + // read once + const kind = BindenResponse.#kind(data); + // The bytes of a view, read once by the getters of the engine (see + // `bytesOf()`): what is sized and written for a `Uint8Array` (any other + // view is refused below). Any view whose bytes are gone (e.g. of a + // detached `ArrayBuffer`) throws a `TypeError` of binden, which `send()` + // rejects with before anything is changed + const bytes = ArrayBuffer.isView(data) ? bytesOf(data) : null; + if (kind === null) { + // Before anything is changed: never its `toString()` (e.g. `[object + // Object]`), nor a source that cannot be read with backpressure or + // released (its methods are not called) + return Promise.reject( + formatStack( + new TypeError( + "The data is not a string, a number, a Buffer, a Uint8Array, a Readable nor a stream-like source (with on(), pause(), resume(), and removeListener() or off())", + ), + ), + ); + } else if ( + typeof encoding !== "undefined" && + !Buffer.isEncoding(encoding) + ) { + return Promise.reject( + formatStack(new TypeError(`Unknown encoding: ${described(encoding)}`)), + ); + } + // Converted once, before anything decides by it (see `#status()`): one + // that is refused releases the source, as the refusal of a status below + const code = this.#sending(data, kind); + if (BindenResponse.#informational(code)) { + this.#release(data, kind); + return Promise.reject(BindenResponse.#notFinal(code)); + } + // What a value restores when Node.js refuses the head that it commits + // (see `#unsent()`) + const before = + kind === "value" && !this.headersSent ? this[kSaveFraming]() : null; + if (type !== null) { + // Accepted: the media type of `text()`, `html()`, `json()` or `form()` + this.setHeader("Content-Type", type); + } + + if (kind !== "value") { + // Read once, before anything of the source runs (see `#pipe()`) + const declared = this.#declared(data, kind); + if (this.#bodiless()) { + // Nothing of the source would be sent: it is not read at all + this.#release(data, kind); + // The representation and the framing of the length that it declares + // (unknown without one, not empty), without a stale `Transfer-Encoding` + this[kRepresentation](declared); + this.#frame(declared); + this.#remove(...contentDigests); + return this.#end(); + } + return this.#pipe( + kind === "readable" + ? (data as Readable) + : readStreamLike( + data as object, + kind, + () => this.#abandoned(BindenResponse.#premature()), + this.#report(), + () => { + this.#unreleased(); + }, + this.#streamLikeLogs(), + ), + declared, + ); + } + + if (typeof data === "undefined" || this.destroyed) { + // No body: as for `GET`, `Content-Length: 0`, but a valid length given + // to `HEAD` (of the body that `GET` would send) is kept, without a + // stale `Transfer-Encoding` + this.#frame(this.req.method === "HEAD" ? (this.#length() ?? 0) : 0); + if (this.#bodiless()) { + // Of the body of `GET` (e.g. of `sendFile()`), which is not sent + this.#remove(...contentDigests); + } + return this.#unsent(before, () => this.#end()); + } + + // Encoded once: the bytes give the length and the `ETag`, and are sent + const body = + typeof data === "string" + ? Buffer.from(data, encoding) + : (bytes ?? Buffer.from(String(data as bigint | number))); + + // Only for a status with a body: also on a `304` that stands for the + // `200` (see below) + if ( + !this.headersSent && + typeof this.getHeader("Content-Type") === "undefined" && + !BindenResponse.#noContent(this.statusCode) + ) { + this.setHeader("Content-Type", ct_text); + } + + const status = this.#conditional(body); + if (status === 304) { + return this.#unsent(before, () => this.#endNotModified(body.length)); + } else if (status !== null) { + // Empty, also for `HEAD` (which would keep the length of a body), and + // without the coding, the digests (e.g. of a precompressed body) nor + // the trailers (which Node.js refuses without chunks) of the body whose + // representation it replaces + this.statusCode = status; + this.#remove( + "Content-Encoding", + "Trailer", + ...contentDigests, + ...representationDigests, + ); + this.#frame(0); + return this.#unsent(before, () => this.#end()); + } + + // Counted once: the bytes that the head declares (see `bytesLost()`) + const { length } = body; + this.#frame(length); + + if (this.#bodiless()) { + // The headers of the body (as for `GET`), but not the body itself, nor + // the digest of its content (only of its representation) + this.#remove(...contentDigests); + return this.#unsent(before, () => this.#end()); + } + + // 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. + return new Promise((resolve, reject) => { + const discarded = this.#discardedWrites; + this.#unsent(before, () => + this.write(body, (error?: Error | null) => { + if (error) { + reject(error); + } + }), + ); + if (bytesLost(body, length) && !this.#dropped(discarded)) { + // Detached by code of the application inside that write (see + // `bytesLost()`): never flushed, also once that write has ended the + // response, unless the write has been discarded (see `#dropped()`) + this.#cutLost(); + reject(detachedView()); + return; + } + this.#end().then(resolve, reject); + }); + } + + /** + * Ends the response, then settles once it has finished, or rejects when it + * is closed before (e.g. the client went away). Listened to once `end()` has + * returned: an `end()` that throws (e.g. for a reason phrase that Node.js + * refuses) leaves no listener, nor a promise that would reject unobserved. + */ + #end(): Promise { + this.end(); + return this.#finished(() => false); + } + + /** + * Runs the commit of a value send (its `write()` or its `end()`): when + * Node.js refuses its head (e.g. a status out of range, a reason phrase, or + * a `Trailer` of a body that is not chunked), which stays uncommitted, the + * response is restored as it was before the send (`state`, see + * `[kSaveFraming]()`): its status and reason phrase, its headers (without + * the `Content-Type`, the `ETag` and the `Content-Length` that the send has + * set, nor the status of a `304`, `406` or `412` that has replaced its + * body), and the framing fields of Node.js. Then, as for a failed send of a + * stream or a file (see `#unframe()`, `#unwind()`), it has no framing of a + * body (a `Content-Length` or a `Transfer-Encoding` set before the send, + * whose body it was), nor the representation chosen for it (see + * `#forget()`), so that the reply that follows (e.g. of the error) frames + * its own body. The work that sends may mutate the response throughout: + * the send is ignored otherwise, and nothing revokes it within the call. + */ + #unsent(state: IFramingState | null, commit: () => T): T { + try { + return commit(); + } catch (error: unknown) { + if (state !== null && !this.headersSent) { + this[kRestoreFraming](state); + this.#remove("Content-Length", "Transfer-Encoding", "Content-Range"); + this.#forget(); + } + throw error; + } + } + + /** + * With the dynamic `ETag` (see `options`), for a body of a `200` response + * to `GET` or `HEAD`: sets its strong `ETag` (unless it has one already), + * chooses its representation (see `[kRepresentation]()`), then evaluates the + * preconditions of the request against the `ETag` of that representation + * and the `Last-Modified` of the response (RFC 9110, section 13.2.2). The + * status of the empty reply instead of the body: `304`, `412`, or the + * status that replaces the representation (e.g. `406 Not Acceptable`, + * whatever the preconditions are); `null` to send the body. + */ + #conditional(body: Buffer): number | null { + const { method = "GET" } = this.req; + if ( + this.headersSent || + !this.options.etag || + this.statusCode !== 200 || + (method !== "GET" && method !== "HEAD") + ) { + return null; + } + let etag = this.getHeader("ETag"); + if (typeof etag === "undefined") { + etag = `"${createHash("sha256").update(body).digest("base64url")}"`; + this.setHeader("ETag", etag); + } + const representation = this[kRepresentation](body.length); + if (representation.status !== null) { + return representation.status; + } + const modified = this.getHeader("Last-Modified"); + return preconditionsOf( + this.req, + tagOf(etag, representation), + typeof modified === "string" ? parseHttpDate(modified.trim()) : null, + ); + } + + /** + * Ends the response (whose headers are not sent yet) as `304 Not Modified` + * for a `200` whose body has `length` bytes: the validators and the other + * headers of the `200`, with its `Content-Length` (RFC 9110, section 8.6) + * and the digests of its representation, but no body (nor the digest of + * its content) + */ + #endNotModified(length: number): Promise { + this.statusCode = 304; + // Without the trailers of the body (see `#frame()`) + this.#remove("Transfer-Encoding", "Trailer", ...contentDigests); + this.setHeader("Content-Length", length); + return this.#end(); + } + + /** + * 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 + * `stringify` that is not a function, or that does not return a string; + * what a `stringify` throws (e.g. one that cannot be called) is formatted + * (see `formatting()`). Ignored as `send()` is, once the response has + * ended or the work may no longer mutate it: neither the value nor + * `stringify` is read, checked or called. + */ + public async json( + data: T, + stringify?: (data: T) => string, + ): Promise { + if (this.#ignored()) { + return; + } + const type = typeof data; + if ( + type === "undefined" || + type === "function" || + type === "symbol" || + type === "bigint" + ) { + throw formatStack(new TypeError(`A ${type} is not a JSON value`)); + } else if ( + typeof stringify !== "undefined" && + typeof stringify !== "function" + ) { + throw formatStack(new TypeError("`stringify` is not a function")); + } + const body: unknown = + typeof stringify === "undefined" + ? jsonText(data) + : formatting(() => stringify(data)); + if (typeof body !== "string") { + throw formatStack(new TypeError("The JSON text is not a string")); + } + await this.#sendAs(body, ct_json); + } + + /** + * Send text as `text/plain; charset=utf-8`: data that `send()` refuses (or + * a 1xx status) rejects before the `Content-Type` is set + */ + public async text(data: string): Promise { + if (!this.#allows()) { + return; + } + await this.#sendAs(data, `${ct_text}; charset=utf-8`); + } + + /** + * Send text as `text/html; charset=utf-8`: data that `send()` refuses (or + * a 1xx status) rejects before the `Content-Type` is set + */ + public async html(data: string): Promise { + if (!this.#allows()) { + return; + } + await this.#sendAs(data, `${ct_html}; charset=utf-8`); + } + + /** + * `send()` of data as a media type, which is set once `send()` has accepted + * the data and the status (see `#send()`) + */ + #sendAs(data: unknown, type: string): Promise { + try { + return this.#send(data, type); + } catch (error: unknown) { + // As `send()`: the response is unchanged, and the error can be replied + return Promise.reject(formatStack(error as Error)); + } + } + + /** + * Send `URLSearchParams` as `application/x-www-form-urlencoded`: anything + * else rejects with a `TypeError` before the response is started (see + * `formText()`). Ignored as `send()` is, once the response has ended or + * the work may no longer mutate it: nothing of the parameters is read nor + * checked. + */ + public async form(params: URLSearchParams): Promise { + if (this.#ignored()) { + return; + } + const text = formText(params); + await this.#sendAs(text, ct_form); + } + + /** + * 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`, a status that is not a redirect, and options that are + * not an object. What reading the location or the options throws (e.g. of + * a revoked proxy) is formatted (see `formatting()`). + */ + public async redirect( + location: URL | string, + status: IRedirectStatus = 302, + options: IRedirectOptions = {}, + ): Promise { + if (!this.#allows()) { + return; + } else if (!isRedirectStatus(status)) { + throw formatStack( + new TypeError(`Status code ${described(status)} is not a redirect`), + ); + } + // Checked as given (e.g. `null` by JavaScript) + const given: unknown = options; + if (typeof given !== "object" || given === null) { + throw formatStack(new TypeError("`options` is not an object")); + } + // Read as destructuring reads it (also an inherited one) + const allowed: unknown = formatting(() => options.allow_external); + const allow_external = typeof allowed === "undefined" ? false : allowed; + if (typeof allow_external !== "boolean") { + throw formatStack(new TypeError("`allow_external` is not a boolean")); + } + const { base, origin } = this.#base(); + const value = formatting(() => + 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 + * (e.g. the client went away). `@binden/compression` replaces it to flush + * the compressor as well. + */ + public flush(): Promise { + if (!this.#allows() || this.writableFinished) { + return Promise.resolve(); + } else if (this.writableEnded || this.destroyed) { + return this.#finished(() => false); + } + return new Promise((resolve, reject) => { + // First: when it throws (e.g. for a reason phrase that Node.js refuses), + // the promise rejects without a listener left + this.flushHeaders(); + const listening: (() => void)[] = []; + const unlisten = (): void => { + for (const dispose of listening) { + dispose(); + } + }; + const fail = (error?: Error): void => { + unlisten(); + reject(error ?? BindenResponse.#premature()); + }; + listening.push( + this[kListen]("close", fail), + this[kListen]("error", fail), + ); + // Its callback runs once the writes before it are flushed + this.write("", (error?: Error | null) => { + unlisten(); + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + /** + * The length that the body of a source declares (see `#length()`): one of + * another type refuses its send before anything is sent, and the source is + * released (see `#release()`), as for an informational status + */ + #declared( + data: unknown, + kind: IStreamLikeMethods | "readable", + ): number | null { + try { + return this.#length(); + } catch (error: unknown) { + this.#release(data, kind); + throw error; + } + } + + /** A response without a body: to `HEAD`, or with a 1xx, 204, 205 or 304 status */ + #bodiless(): boolean { + return ( + this.req.method === "HEAD" || BindenResponse.#noContent(this.statusCode) + ); + } + + /** An informational status (1xx): not of a final response */ + static #informational(status: number): boolean { + return status >= 100 && status < 200; + } + + /** + * The refusal of a send by an informational status (see `#informational()`), + * formatted (see `formatStack()`): not a final response, whose client + * would wait for the final one + */ + static #notFinal(status: number): Error { + return formatStack( + new TypeError( + `Status code ${String(status)} is informational (use writeEarlyHints())`, + ), + ); + } + + /** A status whose responses never have a body */ + static #noContent(status: number): boolean { + return ( + BindenResponse.#informational(status) || + 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. A response without a + * body has no `Trailer` either (e.g. one set for the chunked body of `GET`). + */ + #frame(length: number | null): void { + const { statusCode } = this; + if (this.headersSent) { + return; + } else if (this.#bodiless()) { + // Nor the trailers of a body, which Node.js refuses without chunks + // (also to `HEAD`, which may omit the fields that only generating the + // content determines, RFC 9110, section 9.3.2) + this.#remove("Trailer"); + } + if (statusCode === 205) { + // No content, framed as such (RFC 9110, section 15.3.6) + this.#remove("Transfer-Encoding"); + this.setHeader("Content-Length", 0); + } else if (BindenResponse.#noContent(statusCode)) { + // Never a body, nor the length of one + this.#remove("Content-Length", "Transfer-Encoding"); + } else if (length !== null) { + // Also for `HEAD`: the length of the body it would send + this.#remove("Transfer-Encoding"); + this.setHeader("Content-Length", length); + } + } + + /** + * Removes the headers that are set. Node.js also remembers the removal of + * a header that is not set: once both `Transfer-Encoding` and + * `Content-Length` have been removed (e.g. the length by a compression), + * it frames the body by closing the connection, instead of chunking it. + */ + #remove(...names: string[]): void { + for (const name of names) { + if (this.hasHeader(name)) { + this.removeHeader(name); + } + } + } + + /** + * 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; an empty one (`Content-Length: 0`) only + * once the response has finished, since nothing of it is written (a client + * that goes away before may not have its head). + * + * The `Content-Length` that the body declares (`declared`, see `#send()`) is + * read once, before anything of the source runs (a compression removes the + * header): a source that fails, closes before its end, or does not have + * exactly that length rejects. + * Nothing sent yet, before the headers are committed, the error can still + * be replied (see `.started`); otherwise the response is closed (see + * `#cutFailed()`), so that a truncated body (or a head that promises one, + * e.g. once `flushHeaders()` has sent it) does not look complete, and more + * bytes are not read by the client as the next response of the connection. + * A source that has already ended is an empty body; one that has been + * destroyed or has errored before rejects at once (see `#unreadable()`). + * One that is paused (e.g. by the application, or by an `unpipe()`) is + * resumed once the listeners of the send are added, as + * `Readable.prototype.pipe()` does: once the response drains when it needs + * to. + * + * Code of the source that throws (its own `on()`, e.g. by a listener of its + * `newListener`, or its `resume()`: also the one that `on("data")` of + * Node.js calls for a source that is not paused, and the one at a drain) + * fails the send as binden fails it itself (see `fail`), with what it has + * thrown, as it is (also `null`): `done` is observed before any code of + * the source runs, so that nothing of the send is left. + * + * Once the response has been ended (by another writer, e.g. a compression + * that has replaced it with `406` when its first chunk committed the + * headers, or by the send itself, once the whole body is written), the + * rest of the source is not read, and its failure no longer fails the send + * (see `stop`): it is released, and the response is left as it is. + * + * The callbacks of the source run as the owner that has started the send, + * whose rights they keep: once that owner may no longer mutate the + * response, they do not either. Before the headers are committed, the + * framework may cancel the send (see `[kCancel]()`): it then rejects with + * the reason at once, and its source is released. + * + * When the response closes before the send has settled (e.g. the client + * goes away), the send rejects with the error of the connection when a + * pending write fails first (e.g. `EPIPE`, `ECONNRESET` or `ECANCELED`, + * which Node.js and the system decide), otherwise at once as the response + * has ended (see `unclose`): as a premature close, or resolved once its + * body is complete for the client. Only then is its source released: a + * release that fails never decides it. + * + * Once the send has settled (sent, failed or cancelled), every listener + * that it has added to the source (by its own `on()`, also of `end` and + * `close`, which a source that has ended or closed before never emits) is + * removed by its own `removeListener()` (or `off()`), which pairs with + * that `on()` (e.g. one that wraps them, such as the `context.bind()` of + * OpenTelemetry), and every observer of the response is removed, so that + * a source that the application keeps holds nothing of the response, and + * the listeners of the application stay. The guard of the source listens + * before, and reports a late error of it (e.g. of a destroy by + * `autoDestroy` that fails after its end); a removal that throws is + * logged (`debug`). + */ + #pipe(data: Readable, declared: number | null): Promise { + if (!data.readableEnded) { + // Read once (e.g. a getter of the source) + const { destroyed, errored } = data; + if (destroyed || errored !== null) { + // The value that the readable of a stream-like source stands for, in + // place of its carrier (see `uncarried()`) + return this.#unreadable( + data, + destroyed, + uncarried(errored, data) as Error | null, + ); + } + } + const operation = this.#operation(); + // A body of a declared length is framed by it: a stale + // `Transfer-Encoding` would frame it by chunks as well (RFC 9112, + // section 6.1), which a client or a proxy may read either way + this.#frame(declared); + // The framing of this send (see `#unframe()`) + const framed = this.#framing; + const key = {}; + let flushed = 0; + let sent = 0; + // Once the response is closed, `#finished()` tells how it ended + let closed = false; + // Once the response has been ended (see `stop`) + let stopped = false; + let settled = false; + // Once the source has emitted an `error` (see its `close`) + let heard = false; + /** What removes the observers that `#finished()` adds to the response */ + const finishing: (() => void)[] = []; + /** + * How the response has ended for the send (see `#finished()`): its + * error, `null` once it has finished (or is complete for the client). + * Told as `done` settles, before its reactions, and before the `close` + * of the response reaches `unclose` (`done` observes it first). + */ + let ending: Error | null = null; + const done = this.#finished( + () => { + // An empty body only once the response has finished: nothing is + // written of it, and the client may not even have its head. Read as + // the response closes (e.g. removed by a compression), never by a + // listener that throws: bytes have been flushed only once the head + // has been committed, whose `Content-Length` binden has checked (see + // `kValidateHead`), and a value of another type is no length here + if (flushed === 0) { + return false; + } + const length = this.#knownLength(); + return length !== null && flushed >= length; + }, + finishing, + (error) => { + ending = error; + }, + ); + return this.#cancellable(key, (resolve, reject) => { + /** The listeners that the send adds to the source (see `listening`) */ + const listeners: [string, (...args: never[]) => void][] = []; + /** + * Whether the listener of `error` of the send is in place (see + * `errors`): its `on()` has returned, or it has heard an error. Kept + * once the send has settled when no guard listens (see `unlisten`) + */ + let listened = false; + /** Whether the guard of the source listens, once the send has settled */ + let guarded = false; + /** + * The listener of `error` of the send (every error, also one emitted + * while the source is destroyed later), which holds nothing of the + * send once it has settled (see `forwarder()`). The readable of a + * stream-like source fails the send with the value of its source, in + * place of its carrier (see `uncarried()`) + */ + const errors = forwarder((error: unknown) => { + heard = true; + listened = true; + operation.run(() => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- only once it is added, below + fail(uncarried(error, data), false, true); + }); + }); + /** + * What removes the other observers that the send adds to the response: + * of its `close`, and of each `drain` that it waits for + */ + const observers = new Set<() => void>(); + /** + * Removes every listener of the source and every observer of the + * response that the send has added, once it has settled, so that a + * source that lives on holds nothing of the response: the guard of the + * source (see `#guardReleased()`) listens before, and reports a late + * error of it (e.g. of a destroy by `autoDestroy` that fails after its + * end). So does the guard of the request of the response, which the + * send never destroys (see `#destroys()`), but whose error Node.js may + * already have scheduled once it has found the listener of the send + * (e.g. of a client that aborts): whether a source needs a guard does + * not depend on whether it may be destroyed + */ + const unlisten = (): void => { + // First: an error that the source emits while the listener of its + // guard is added (e.g. by a listener of its `newListener`) is a late + // one (see `forwarder()`), never one of the send, which has settled + errors.detach(data, this.#report()); + guarded = this.#guardReleased(data); + for (const [event, listener] of listeners) { + // Kept when no guard listens, without which an error of its + // release (see `drop`) would crash the process: it reports a late + // error itself (see `forwarder()`), heard as long as it listens + if (listener !== errors.listener || !listened || guarded) { + try { + Reflect.apply(removerOf(data), data, [event, listener]); + } catch (error: unknown) { + // Its guard keeps it harmless: once the send has settled, its + // listeners change nothing of the response + const message = + "The removal of a listener of a stream has failed"; + this.#ownership.debug(message, error); + } finally { + if (listener === errors.listener) { + // No longer heard by the send, even when a removal that + // throws leaves it: once detached, it reports an error that + // the guard does not (see `forwarder()`) + hear(data, -1); + } + } + } + } + for (const dispose of [...finishing, ...observers]) { + dispose(); + } + }; + /** + * Destroys the source under its guard, by one call of its own + * `destroy()`, with the error that binden has failed the send with + * (`failure`), or without one. Node.js reads the `stack` of that error + * (before anything is destroyed, and again once its `_destroy()` has + * called back): it is the error itself only when Node.js reads it + * without running code of the application (see `readsSafely()`), and + * otherwise an `Error` that carries it as its `cause` (see + * `carrierOf()`), so that the source is destroyed in any case (e.g. its + * file closed). The `destroy()` of Node.js (`Readable.prototype`, read + * once), which throws before it destroys the stream (e.g. as it reads + * that `stack`), is called once more, with the carrier of the error. + * What else throws is thrown (see `drop`), and a `destroy()` of the + * source's own is never called again: it may have done anything before. + */ + const destroyWith = ( + failure: { readonly error: unknown } | null, + ): void => { + if (failure === null) { + data.destroy(); + return; + } + // Read once (e.g. by a getter of the source), before the error is + // checked + const destroy = Reflect.get(data, "destroy") as ( + error: unknown, + ) => unknown; + const { error } = failure; + const safe = readsSafely(error); + try { + Reflect.apply(destroy, data, [safe ? error : carrierOf(error)]); + } catch (thrown: unknown) { + if ( + destroy !== destroyReadable || + !safe || + Reflect.apply(readableDestroyed, data, []) + ) { + throw thrown; + } + Reflect.apply(destroyReadable, data, [carrierOf(error)]); + } + }; + /** + * Releases the source that the send no longer reads, unless it has + * ended or is destroyed: it is destroyed (with the error that binden + * has failed the send with, `failure`, see `fail`), unless it is the + * request of the response (see `#destroys()`), which is only left, no + * longer paused (e.g. by the backpressure of the send, once another + * writer has ended the response), so that Node.js reads the rest of its + * body, and the next request of the connection. Without its guard (see + * `unlisten`), it is destroyed without that error, which the send has + * failed with already, while the listener of `error` of the send + * listens, and else left (see `#unreleased()`). A failure of the release + * (e.g. of a `destroy()` of its own) is logged (`debug`) + */ + const drop = ( + failure: { readonly error: unknown } | null = null, + ): void => { + try { + if (data.readableEnded || data.destroyed) { + return; + } else if (!this.#destroys(data)) { + if (data.isPaused()) { + data.resume(); + } + } else if (guarded) { + // Its guard does not report it again when the source emits it + // (decided, see `settle`) + destroyWith(failure); + } else if (listened) { + data.destroy(); + } else { + this.#unreleased(); + } + } catch (error: unknown) { + this.#ownership.debug("The release of a stream has failed", error); + } + }; + /** + * Decides an error that the source has brought (by an event of it, or + * thrown by its code) for that source (see `decidedFor()`), unless a + * cancellation recorded for the send (see `[kCancelling]()`, e.g. of a + * failed error handler or of a failed pipe) replaces it with another + * reason, which the send rejects with: the error then decides nothing, + * and is a late error, reported once on a later turn (see + * `reportLate()`) + */ + const brought = (error: unknown): void => { + const cancellation = this.#cancellations.get(key); + if ( + typeof cancellation === "undefined" || + cancellation.reason === error + ) { + decidedFor(data, error); + } else { + reportLate(data, error, this.#report()); + } + }; + /** + * Settles the send once: it resolves without a `failure`, otherwise it + * rejects with its error, whatever it is (e.g. the `null` that code of + * the source has thrown, or the reason of a cancellation). Only then, a + * source still read (neither ended nor destroyed, e.g. once another + * writer has ended the response) is released, as it would be once the + * response has closed: with the error that binden fails the send with + * itself (`own`, see `fail`). A send that `[kCancel]()` cancels removes + * its listeners and releases its source afterwards (`release`), without + * the rights of the framework: that runs code of the source (e.g. its + * own `removeListener()`), which belongs to the work that is abandoned. + */ + const settle = ( + failure: { + readonly error: unknown; + readonly own: boolean | "thrown"; + } | null, + release: (() => void)[] | null = null, + ): void => { + if (settled) { + return; + } + settled = true; + if (failure === null) { + // Before it is released (see `drop`): a stream-like source is then + // destroyed without an error, as a Node.js `Readable` is, never + // with the reason of a cancellation (see `resolved()`) + resolved(data); + } else if (failure.own === "thrown") { + // What code of the source has thrown, an object of the + // application, before its guard is installed (see `unlisten`): its + // code that throws it again then is not logged (see `guard()`), nor + // is it reported once the source emits it (see `drop`), but it may + // still be a late error of another source (see `decidedFor()`) + brought(failure.error); + } else if (failure.own) { + // An error of binden (e.g. a body longer than its + // `Content-Length`), decided as above for every source + decided(failure.error); + } + // Restored by `[kCancel]()` still, once its cancellation is recorded + this.#settled(key); + if (release === null) { + unlisten(); + } else { + release.push(unlisten, () => { + this.#discard(data, listened); + }); + } + if (failure === null) { + resolve(); + } else { + // Unless `[kCancel]()` cancels it (`release`): it has removed the + // framing, and forgotten the representation, once for every send + if (release === null && !this.headersSent) { + this.#forget(); + this.#unframe(framed); + } + // Abandoned at the deadline (e.g. its source destroyed by a + // listener of `signal`, before the deadline cancels it), it fails + // as a cancelled send (whose reason may be anything) + const error = failure.error as Error; + reject(operation.active ? error : this.#abandoned(error)); + } + if (release === null) { + drop(failure !== null && failure.own !== false ? failure : null); + } + }; + /** + * Stops the send once the response has been ended (see `fail`): the + * source is released (see `#discard()`), and the response is left as + * it is (its reply is complete, and the responses that follow it on + * the connection are sent), which the send settles as (see `done`). + * The error of the source is reported once, as a late error (see + * `reportLate()`; `emitted`: whether an event of the source has + * brought it, `null` for none), by the send also when the source has + * emitted it: the send hears it (see `hear()`), so its guard does not + * report it. It is reported on a later turn (see `reportLate()`), once + * the event that may have brought it has reached every listener of the + * source: a live send of the source (e.g. of another response) that + * fails with that error in the same event has decided it for the source + * then (see `fail`), and it is not reported, whichever of them listens + * first. The guard of the source is installed while the send decides + * that error (see `decide()`): code of the source that throws that same + * object for a listener of the guard (e.g. a listener of its + * `newListener`) is not logged as a refusal of the guard as well + */ + const stop = (error: unknown, emitted: boolean | null): void => { + if (emitted !== null) { + reportLate(data, error, this.#report()); + } + stopped = true; + decide(data, error, () => { + this.#discard(data, listened); + }); + }; + /** + * Fails the send: once anything is sent, or the headers are committed + * (e.g. by `flushHeaders()`, whose head promises a body), the response is + * closed (see `#cutFailed()`), so that the client never takes it for + * complete, nor reads the next response of the connection as its body. A + * failure that binden finds itself (`own`: `true` for an error of binden + * or of Node.js, e.g. a body longer than its `Content-Length`; `"thrown"` + * for what code of the source throws, an object of the application, + * decided for this source only) fails it at once, never by an `error` + * event of the source (which a `_destroy()` that calls back without the + * error never emits), then destroys the source with it under its guard + * (see `settle`), unless it is the request of the response (see + * `#destroys()`). Once the response has been ended (by another writer, + * e.g. a compression that has replaced it with `406`, or by the send + * itself, once the whole body is written), a failure no longer fails the + * send, which is stopped instead (see `stop`, `emitted`). An error that + * an event of the source has brought (`emitted`: its `error`, or its + * `close` with its `errored`, which reach every listener of the source; + * `null` for none) and that fails the send has decided it for that source + * (see `decidedFor()`): no guard of the source reports it, e.g. the one + * that another response has installed on a source that it has released, + * whose listener of `error` comes after the one of the send + */ + const fail = ( + error: unknown, + own: boolean | "thrown" = false, + emitted: boolean | null = false, + ): void => { + if (settled) { + return; + } else if (stopped || this.writableEnded) { + stop(error, emitted); + return; + } else if (sent > 0 || this.headersSent) { + this.#cutFailed(); + } + if (emitted === true) { + // For this source only: the same object of the application (e.g. + // one that it destroys each of its streams with) may still be a + // late error of another source + brought(error); + } + settle({ error, own }); + }; + /** + * Resumes the source that the send has paused, or has found paused: a + * `resume()` of its own that throws fails the send (see `fail`) + */ + const resume = (): void => { + try { + data.resume(); + } catch (error: unknown) { + fail(error, "thrown"); + } + }; + /** + * Resumes the source, which is paused, once the response drains: as the + * owner that has started the send (see `Operation.run()`) + */ + const drain = (): void => { + const drained = this[kListen]("drain", () => { + observers.delete(drained); + operation.run(resume); + }); + observers.add(drained); + }; + /** + * Pauses the source while the response is full, until it drains: a + * `pause()` of its own that throws fails the send (see `fail`) + */ + const pause = (): void => { + try { + data.pause(); + } catch (error: unknown) { + fail(error, "thrown"); + return; + } + drain(); + }; + const end = (): void => { + if (settled || this.writableEnded) { + // Once another writer has ended the response, the send settles as + // the response does (see `stop`) + return; + } else if (declared !== null && sent < declared) { + fail(BindenResponse.#mismatch(sent, declared)); + return; + } + try { + this.end(); + } catch (error: unknown) { + // Nothing sent (e.g. an empty source, whose headers Node.js + // refuses): never thrown out of the events of the source + fail(error); + } + }; + // Before any code of the source runs, which may fail the send + done.then( + () => { + settle(null); + }, + (error: unknown) => { + settle({ error, own: false }); + }, + ); + // Nothing of its own to restore: `[kCancel]()` removes the framing of + // the body that is not sent, and its representation (see `#rollbacks`) + this.#rollbacks.set(key, null); + this.#pending.set(key, (reason: unknown, release: (() => void)[]) => { + settle({ error: reason, own: false }, release); + }); + + const unclose = this[kListen]("close", () => { + closed = true; + // Settled at once as the response has ended (see `ending`), before + // its source is released (e.g. the file of `sendFile()`, see + // `settle`): a release that fails (e.g. a `destroy()` of the source + // that destroys it with an error of its own, or a `_destroy()` that + // fails on the next tick) can no longer decide the send, and is + // reported by the guard of the source + settle(ending === null ? null : { error: ending, own: false }); + }); + observers.add(unclose); + /** + * Adds the listeners of the send to the source by its own `on()`, and + * records them first (an `on()` that throws may have added it), so + * that its own `removeListener()` removes them (see `unlisten`): never + * by `once()`, whose wrapper a `removeListener()` that pairs with a + * wrapping `on()` may not find (e.g. of a wrapper that patches `on()` + * and `removeListener()`, but not `once()`). None once the send has + * settled (e.g. by an `error` that its `on()` has emitted at once) + */ + const listening: IListening = { + on: (event, listener) => { + if (!settled) { + listeners.push([event, listener]); + if (listener === errors.listener) { + // Heard by the send (see `hear()`): until it is removed (see + // `unlisten`), its error fails the send, and is not reported + // by the guard of the source (e.g. installed by another + // response that has released it) + hear(data, 1); + } + data.on(event, listener as IListener); + if (listener === errors.listener) { + listened = true; + } + } + return listening; + }, + }; + try { + listening + // First: nothing of the source runs (e.g. the `resume()` that + // `on("data")` of Node.js calls, which emits `resume` to the + // listeners of the application) until a listener of its `error` is + // in place, without which a later error of it would crash the + // process (see `#unreleased()`) + .on("error", errors.listener) + .on("data", (chunk: Uint8Array | string) => { + operation.run(() => { + if (settled || !operation.active) { + // Cancelled, or its owner may no longer write + this.#discard(data, listened); + return; + } else if (this.writableEnded) { + stopped = true; + // No longer read, it no longer fails the send (see `stop`): + // released without an error (see `resolved()`), as at its + // resolution + resolved(data); + this.#discard(data, listened); + return; + } + try { + // A view (a typed array or a `DataView`, e.g. a chunk of an + // object-mode `Readable`) is read once, as a `Buffer` of + // binden, which is what is sized and written (see + // `bytesOf()`): Node.js reads nothing of it. One whose bytes + // are gone (e.g. of a detached `ArrayBuffer`) is never + // written (Node.js would throw once it writes it, e.g. as it + // uncorks the connection on the next tick, out of any call): + // what it throws is an error of binden, decided as below + const written = ArrayBuffer.isView(chunk) + ? bytesOf(chunk) + : chunk; + const size = Buffer.byteLength(written); + if (declared !== null && sent + size > declared) { + fail(BindenResponse.#mismatch(sent + size, declared), true); + return; + } + const discarded = this.#discardedWrites; + const accepted = this.write(written, (error?: Error | null) => { + if (error) { + // Not delivered: the client went away, and the error of + // the connection (e.g. `EPIPE`, `ECONNRESET` or + // `ECANCELED`) may come before its close, which the send + // then rejects with (Node.js 24 may still emit `finish` + // once the response is ended) + settle({ error, own: false }); + } else { + flushed += size; + } + }); + if ( + written !== chunk && + bytesLost(written as Buffer, size) && + !this.#dropped(discarded) + ) { + // Detached by code of the application inside that write + // (see `bytesLost()`): never flushed, and the send rejects, + // also once that write has ended the response (which + // `fail` would leave as it is), unless the write has been + // discarded (see `#dropped()`) + this.#cutLost(); + settle({ error: detachedView(), own: true }); + return; + } + // Once written: a first write that throws (e.g. for a reason + // phrase that Node.js refuses) has sent nothing + sent += size; + if (!accepted) { + pause(); + } + } catch (error: unknown) { + // Not a chunk of bytes (e.g. an object), a view whose bytes + // are gone (see above), or a write that Node.js refuses: an + // error of its own + fail(error, true); + } + }); + }) + .on("end", () => { + operation.run(end); + }) + .on("close", () => { + // Destroyed before its end, without an `error` event (which + // precedes the close): with its error (e.g. of `destroy(error)`, + // whose `_destroy()` calls back without it), which its `close` + // brings to every listener of the source, as an `error` event + // would (see `fail`), or without one (nothing to report once the + // response has ended, see `stop`) + if (!closed && !heard && !data.readableEnded) { + operation.run(() => { + const { errored } = data; + fail( + errored ?? BindenResponse.#premature(), + false, + errored === null ? null : true, + ); + }); + } + }); + if (settled) { + // E.g. by an `error` that its `on()` has emitted at once + } else if (data.readableEnded) { + // Its end has been read already: an empty body + end(); + } else if (data.readableFlowing !== true) { + // Paused (e.g. by the application, by an `unpipe()`, or the request + // that a body parser has refused), which a listener of `data` does + // not resume: read, as `Readable.prototype.pipe()` does, once the + // response has room + if (this.writableNeedDrain) { + drain(); + } else { + data.resume(); + } + } + } catch (error: unknown) { + // Code of the source (e.g. its own `on()`, a listener of its + // `newListener`, or its own `resume()`, also the one that `on("data")` + // of Node.js calls for a source that is not paused) + fail(error, "thrown"); + } + }); + } + + /** + * The send of a `Readable` that nothing more can be read of (see + * `#pipe()`): `destroyed`, or `errored` (e.g. without `autoDestroy`), + * before its end. Its destroy (e.g. by the application) may still fail: + * its guard listens, as for a stream-like source (see `#guardReleased()`). + * It rejects at once, with the error of the source (its `errored`, which + * decides the send while its guard is installed, see `decide()`, and then + * for that source: its guard does not report it, see `decidedFor()`), + * or as a premature close, as a send that fails in `#pipe()`: before the + * headers are committed, without the framing of its body (see + * `#unframe()`), nor the representation chosen for it (see `#forget()`), + * so that a reply that follows (e.g. an `end()` of the work that catches + * the rejection) frames its own body; once they are committed, the + * response is closed (see `#cutFailed()`), whose head promises a body that + * will not come. Once another writer has ended the response (e.g. code of + * the source, such as a getter that the checks of `#send()` have run), the + * response is left as it is: the send settles as the response does, and + * the error of the source is reported once, as a late error, whatever + * reports it (see `reportLate()`). Then a source that has errored without + * being destroyed is released (see `#discard()`), so that what it holds + * (e.g. the file of a stream) is freed. + */ + #unreadable( + data: Readable, + destroyed: boolean, + errored: Error | null, + ): Promise { + // Decided meanwhile (see `decide()`): code of the source that emits its + // error while its guard is installed (e.g. a listener of its + // `newListener`) is not reported by the guard, nor logged when it throws + // it. Code of the source that ends the response meanwhile leaves its + // error a late one, which is reported below + decide(data, errored, () => this.#guardReleased(data)); + try { + if (this.writableEnded) { + if (errored !== null) { + reportLate(data, errored, this.#report()); + } + return this.#finished(() => false); + } else if (this.headersSent) { + this.#cutFailed(); + } else { + this.#forget(); + this.#unframe(this.#framing); + } + // For this source only (see `decidedFor()`), as an error that it emits + // while it is sent + decidedFor(data, errored); + return Promise.reject(errored ?? BindenResponse.#premature()); + } finally { + // Once the send has settled, or knows how it settles + if (!destroyed) { + this.#discard(data); + } + } + } + + /** + * Installs the guard of a source (see `guard()`), which a send no longer + * listens to: whether it listens. Code of the source that throws (e.g. a + * listener of its `newListener`) is logged (`debug`): a source that + * refuses the listener of `error` of its guard is not guarded, and is not + * destroyed without another listener of `error` (see `#discard()`). Never + * an object known for the source (see `guard()`): an error that has + * decided a send, or that a send of the source decides meanwhile (see + * `decide()`, e.g. in `#unreadable()`), which its rejection, or its late + * report, tells + */ + #guardReleased(source: Readable): boolean { + return guard( + source, + emitterMethods, + this.#report(), + hasClosed(source), + (error: unknown) => { + this.#ownership.debug("The release of a stream has failed", error); + }, + ); + } + + /** + * Leaves a source that cannot be released: no listener of its `error` is + * in place (it refuses the one of its guard, see `guard()`, and its send + * has none), without which an error of its destroy, or of its pause (e.g. + * of a listener of its `pause` that fails later), would crash the process. + * Nothing of it is called, neither its own `pause()` nor + * `Readable.prototype.pause()` (which emits `pause` to the listeners of + * the application): that is only logged (`debug`), and it is the + * application's to release. Also for a stream-like source (see + * `readStreamLike()`). + */ + #unreleased(): void { + this.#ownership.note( + "A stream could not be released", + "No listener of its error event can be added", + ); + } + + /** + * Closes a response whose send has failed once its headers are committed + * (see `#pipe()`), as the framework closes it, while the work that sends it + * may still mutate it: one that waits for the response before it on its + * connection is cut once it gets its socket (see `#cutQueued()`), so that + * its committed head is never sent, and no later response of the + * connection is read as its body + */ + #cutFailed(): void { + if (this.#allows()) { + this[kFramework](() => this.destroy()); + } + } + + /** + * Whether a write of a send (`discarded`, the count of the discarded writes + * before it, see `#discardedWrites`) has been discarded explicitly by the + * library that has replaced the response inside it (see + * `[kDiscardWrite]()`, e.g. the `406` of `@binden/compression`, which + * discards the writes of the application and calls back each of them). + * Bytes that are gone then (see `bytesLost()`) are not part of the + * response, whose head frames its own empty body: no mismatch, and the + * send settles as the response does. Nothing else drops them: the bytes + * that a writer has given to Node.js (by `write()`, or by `end()`), or to + * a compressor, are framed and queued, also when that write has ended the + * response. + */ + #dropped(discarded: number): boolean { + return this.#discardedWrites !== discarded; + } + + /** + * Closes a response whose bytes are gone once binden has written them (see + * `bytesLost()`), with the rights of the framework, before Node.js flushes + * what it has queued: its head declares bytes that are never written (or, + * in chunks, they are dropped), and the client would read the next + * response of the connection as its body. Also once the response has ended + * (e.g. by a writer that has given them to binden, then ended it, inside + * that write), and whichever work may mutate it: one that waits for the response before it on its + * connection is cut once it gets its socket (see `#cutQueued()`). + */ + #cutLost(): void { + this.#cut = true; + this[kFramework](() => this.destroy()); + } + + /** + * Removes the framing of a send that has failed before the headers are + * committed (whatever has ended it), so that the reply that follows does + * not inherit it: with the rights of the framework (its owner may have been + * abandoned), but only while the framing is still the one of that send + * (`generation`): the headers of another reply that has framed the + * response since stay. + */ + #unframe(generation: number): void { + if (generation === this.#framing) { + this[kFramework](() => { + this.#remove("Content-Length", "Transfer-Encoding", "Content-Range"); + }); + } + } + + /** + * The reason of a send whose owner has been abandoned: the reason of the + * signal of the request once it is aborted (as `[kCancel]()` would), its + * own error otherwise + */ + #abandoned(error: Error): Error { + const signal = Exchange.find(this.req)?.signal; + return signal?.aborted === true ? (signal.reason as Error) : error; + } + + /** + * The `Content-Length` of the response, `null` without a valid one (see + * `framingLength()`, which `@binden/compression` reads it by as well). A + * value of another type (or an element, also a hole of an array) throws + * the `TypeError` of `ERR_HTTP_INVALID_HEADER_VALUE` (see + * `lengthValues()`): never converted, never read as no length + */ + #length(): number | null { + return framingLength(this.getHeader("Content-Length")); + } + + /** + * The `Content-Length` of the response (see `#length()`), `null` for a + * value that it refuses: only to tell whether a body is complete for the + * client, never to frame it (e.g. once the head has been committed around + * binden, by `writeHead()` of Node.js) + */ + #knownLength(): number | null { + try { + return this.#length(); + } catch { + return null; + } + } + + /** + * The error of a close before the end, whose stack is formatted (see + * `formatStack()`): a send rejects with it, or destroys its source with it + */ + static #premature(): Error { + return Object.assign(formatStack(new Error("Premature close")), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + } + + /** + * The error of a body that does not have its `Content-Length`, whose stack + * is formatted (see `formatStack()`), as the one of `#premature()` + */ + static #mismatch(size: number, length: number): Error { + const message = `The body (${size} bytes) does not match its Content-Length (${length} bytes)`; + return Object.assign(formatStack(new Error(message)), { + code: "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + }); + } + + /** + * What `send()` sends: a `Readable`, a stream-like source (its methods, see + * `IStreamLike`), a value (bytes, a string, a number or nothing), or + * `null` for another object, which it refuses. What reading the data throws + * (e.g. of a revoked proxy) is formatted (see `formatting()`). + */ + static #kind( + data: unknown, + ): IStreamLikeMethods | "readable" | "value" | null { + return formatting(() => { + if (data instanceof Readable) { + return "readable"; + } else if ( + isUint8Array(data) || + (typeof data !== "object" && typeof data !== "function") + ) { + return "value"; + } + return data === null ? null : streamLikeOf(data); + }); + } + + /** + * Releases a source (a `Readable`, or a stream-like one) that will not be + * sent: a late error of a stream-like one is reported (see `#report()`) + */ + #release( + data: unknown, + kind: IStreamLikeMethods | "readable" | "value" | null, + ): void { + if (kind === "readable") { + this.#discard(data as Readable); + } else if (kind !== null && kind !== "value") { + releaseStreamLike( + data as object, + kind, + this.#report(), + () => { + this.#unreleased(); + }, + this.#streamLikeLogs(), + ); + } + } + + /** + * How a late error of a source that is not sent, or no longer (also the + * request of the response, which is never destroyed), is reported (see + * `ISourceReport`): by the framework (see `IOwnership.released`), or not at + * all + */ + #report(): ISourceReport { + return this.#ownership.released ?? unreported; + } + + /** + * How code of a stream-like source that throws while it is released (its + * guard refused, a `pause()` or a `destroy()`, or a removal of a listener) + * is logged (`debug`, see `IStreamLikeLogs`): with the messages of a + * Node.js `Readable` (see `#guardReleased()`, `#pipe()`), not as a late + * error. They hold the response: no listener of the guard of the source + * holds them (see `guard()`), which only the report of a late error does + * (see `#report()`) + */ + #streamLikeLogs(): IStreamLikeLogs { + return { + released: (error: unknown): void => { + this.#ownership.debug("The release of a stream has failed", error); + }, + removed: (error: unknown): void => { + const message = "The removal of a listener of a stream has failed"; + this.#ownership.debug(message, error); + }, + }; + } + + /** + * Releases the data of a send that is ignored when it is a Node.js + * `Readable` (see `#isReadable()`), whose file (or the like) would stay + * open (see `#discard()`): the send is still ignored. Anything else (e.g. a + * stream-like source, or a `Proxy`) is not touched. + */ + #releaseIgnored(data: unknown): void { + if (BindenResponse.#isReadable(data)) { + this.#discard(data); + } + } + + /** + * Whether a value is a Node.js `Readable`, without running any code of it: + * an object whose prototype chain reaches `Readable.prototype`, read link + * by link, none of which may be a `Proxy` (whose `getPrototypeOf` trap + * would run) + */ + static #isReadable(value: unknown): value is Readable { + if (typeof value !== "object" || value === null) { + return false; + } + for (let link: object | null = value; link !== null;) { + if (isProxy(link)) { + return false; + } + link = Object.getPrototypeOf(link) as object | null; + if (link === Readable.prototype) { + return true; + } + } + return false; + } + + /** + * Releases a Node.js `Readable` that is not sent, or no longer (to a + * response without a body, for an informational status, once its send is + * ignored, cancelled, or stopped by another writer), unless it has emitted + * its `close` already: it is destroyed by `Readable.prototype.destroy()` + * (never through its own `destroy()`, its `_destroy()` runs) under a guard + * that the methods of `EventEmitter` install (see `guard()`), one however + * many sends release it, which reports a late error (e.g. of a file that + * fails to close). A source that refuses its guard is destroyed only + * while the listener of `error` of its send listens (`listened`), and is + * left otherwise (see `#unreleased()`). A failure of the release is logged + * (`debug`). Never the request of the response (e.g. an echo, see + * `#destroys()`). + */ + #discard(source: Readable, listened = false): void { + if (!this.#destroys(source)) { + return; + } + try { + const state = Reflect.get(source, "_readableState") as + { readonly closeEmitted?: unknown } | undefined; + if (state?.closeEmitted !== true) { + // Its guard listens before it is destroyed, or else the listener of + // `error` of its send (`listened`) + if (this.#guardReleased(source) || listened) { + Reflect.apply(destroyReadable, source, []); + } else { + this.#unreleased(); + } + } + } catch (error: unknown) { + const message = "The release of a stream has failed"; + this.#ownership.debug(message, error); + } + } + + /** + * Whether a source that is not sent, or no longer, may be destroyed: not + * the request of the response (e.g. an echo, `send(request)`), whose + * `_destroy()` would destroy the socket when its body has not been read to + * its end, and with it the reply (e.g. of the error that follows) and the + * responses before it on the connection. It is only left (its listeners + * stop reading it): once the reply has finished, Node.js reads the rest of + * its body, and the reply of the framework closes the connection when that + * body has not come in full (see `request.complete`). + */ + #destroys(source: Readable): boolean { + return source !== this.req; + } + + /** + * 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. What removes its observers is added + * to `unlisten` (e.g. for a send that settles otherwise, see `#pipe()`). + * `ended` is told at once how it settles (its error, `null` once it has + * finished, or is complete), before any reaction to the promise runs: e.g. + * for a send that settles at once when the response closes (see `#pipe()`). + */ + #finished( + complete: () => boolean, + unlisten: (() => void)[] = [], + ended?: (error: Error | null) => void, + ): Promise { + return new Promise((resolve, reject) => { + const settle = (error: Error | null): void => { + for (const dispose of unlisten) { + dispose(); + } + ended?.(error); + if (error) { + reject(error); + } else { + resolve(); + } + }; + if (this.destroyed) { + settle(BindenResponse.#premature()); + return; + } + const listeners: [string, (error?: Error) => void][] = []; + listeners.push( + [ + "finish", + (): void => { + settle(null); + }, + ], + [ + "close", + (): void => { + settle(complete() ? null : BindenResponse.#premature()); + }, + ], + [ + "error", + (error?: Error): void => { + settle(error ?? BindenResponse.#premature()); + }, + ], + ); + for (const [event, listener] of listeners) { + unlisten.push(this[kListen](event, listener)); + } + }); + } + + /** + * What refuses a send of a file by its status (`null` when it is + * accepted): converted once, before anything decides by it (see + * `#status()`), one that its conversion refuses (or whose conversion + * throws), or an informational one, as `#send()` refuses it (which would + * refuse the file only once its headers are set) + */ + #fileRefusal(): Error | null { + try { + const code = this.#status(); + return BindenResponse.#informational(code) + ? BindenResponse.#notFinal(code) + : null; + } catch (error: unknown) { + return formatStack(error as Error); + } + } + + /** + * A send of a file (by its key, see `#rollbacks`) has failed once it has + * set the headers of the file, before the head is committed (e.g. refused + * by a status that the application has changed while the file was opened, + * or by Node.js as it commits the head): the response is restored as it was + * before it, without the framing of a body nor the representation chosen + * for it (as by `[kCancel]()`), so that the reply that follows (e.g. of the + * error) has none of the headers of the file. Not once its cancellation is + * recorded (`[kCancel]()` restores it), nor by a work that may no longer + * mutate the response (`owner`). + */ + #unwind(key: object, owner: object | undefined): void { + const rollback = this.#rollbacks.get(key); + if ( + typeof rollback === "function" && + !this.headersSent && + !this.#cancellations.has(key) && + this.#allows(owner) + ) { + this.#rollbacks.delete(key); + rollback(); + this.#remove("Content-Length", "Transfer-Encoding", "Content-Range"); + this.#forget(); + } + } + + /** + * 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. It is the body of the status + * set before (e.g. a page of an error): the preconditions apply to a `2xx` + * (or a `412`) only, the ranges to a `200`. Before the headers are + * committed, the framework may cancel it (see `[kCancel]()`): it then + * rejects with the reason at once, its headers and status are those from + * before it, or from before an earlier send in progress that is cancelled + * with it (e.g. another `sendFile()`), without the framing of a body (e.g. + * a `Content-Length` set before it), and a file opened later is closed. So + * is a file whose send would be ignored (once the response has ended, or + * once the work that has called it may no longer mutate the response, e.g. + * a failed error handler): it then resolves without sending anything, at + * once when it is called so (without reading its options, nor resolving + * its path). + */ + public sendFile( + path: URL | string, + options?: ISendFileOptions, + ): Promise { + // The work that sends the file, whose rights it keeps + const owner = owners.getStore(); + if (this.writableEnded || !this.#allows(owner)) { + // Ignored, as `#send()`: its options are not read, nor its path + // resolved + return Promise.resolve(); + } + // Before anything is read, nor set + const refusal = this.#fileRefusal(); + if (refusal !== null) { + return Promise.reject(refusal); + } + const status = this.statusCode; + const headers = new Map( + this.getHeaderNames().map((name) => [name, this.getHeader(name)]), + ); + const key = {}; + let settled = false; + return this.#cancellable(key, (resolve, reject) => { + // As the settlement of `#pipe()`: a cancellation always fails (with a + // reason that may be anything) + const settle = (error: Error | null, cancelled = false): void => { + if (!settled) { + settled = true; + // Restored by `[kCancel]()` still, once its cancellation is + // recorded (e.g. it has seen that its work may no longer mutate the + // response, or the send of its file has failed in between) + this.#settled(key); + if (error === null && !cancelled) { + resolve(); + return; + } + const failure = error as Error; + reject(failure); + } + }; + // The headers and the status from before the file, which + // `[kCancel]()` restores first (see `#rollbacks`) + this.#rollbacks.set(key, () => { + this.statusCode = status; + for (const name of this.getHeaderNames()) { + if (!headers.has(name)) { + this.removeHeader(name); + } + } + for (const [name, value] of headers) { + if (this.getHeader(name) !== value) { + this.setHeader(name, value as OutgoingHttpHeader); + } + } + }); + // Started at once (see `.started`), until it fails before sending + // anything + this.#pending.set(key, (reason: unknown) => { + settle(reason as Error, true); + }); + sendFileTo( + this, + path, + options, + // Called right after its last check (below): nothing is awaited + // in between + (length) => this.#endNotModified(length), + // Cancelled, or its send would be ignored (see `#send()`) + () => settled || this.writableEnded || !this.#allows(owner), + // It has failed once it has set the headers of the file + () => { + this.#unwind(key, owner); + }, + ).then( + () => { + settle(null); + }, + (error: unknown) => { + // Once cancelled, a late failure (e.g. of a file opened late) is + // ignored + settle(error as Error); + }, + ); + }); + } +} diff --git a/packages/binden/src/route.ts b/packages/binden/src/route.ts new file mode 100644 index 00000000..d8f686a7 --- /dev/null +++ b/packages/binden/src/route.ts @@ -0,0 +1,302 @@ +import { BindenError } from "./error.js"; +import type { IParams } from "./context.js"; +import { formatStack } from "./exchange.js"; + +/** The segments of a route pattern (a union of strings) */ +export type IRouteSegments = + Pattern extends `${infer Head}/${infer Tail}` + ? Head | IRouteSegments + : Pattern; + +/** The name of the parameter of a `:name` or `*name` segment (`never` for a literal) */ +export type IRouteParamName = + 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; + /** 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[]; + /** The name of the `*name` tail, if any */ + readonly wildcard: string | null; +} + +/** 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; + /** 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 formatStack(new TypeError(`Invalid escape in ${pattern}`)); + } + literal += next; + index += 1; + } else if (char === ":" || char === "*") { + throw formatStack( + 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 formatStack( + 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 formatStack( + new TypeError(`Invalid parameter name in ${pattern}`), + ); + } else if (names.has(param)) { + throw formatStack( + new TypeError(`Duplicate parameter name in ${pattern}`), + ); + } else if (sigil === "*" && kind === "mount") { + throw formatStack( + new TypeError(`A mount prefix has no \`*\` (${pattern})`), + ); + } else if (sigil === "*" && index !== raw.length - 1) { + throw formatStack( + 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, 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); + if (decoded === null || (!encoded_slashes && /[/\\]/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, + encoded_slashes: 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], encoded_slashes)]); + } + } + // Defined, not assigned: a parameter may be named `__proto__` + return Object.fromEntries(params); +} + +/** The segments of a path (which starts with `/`), as received */ +export 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 { + /** Whether a parameter may have an encoded `/` or `\` (`false`) */ + readonly encoded_slashes?: boolean | undefined; +} + +/** + * Matches a whole path (or its segments, see `split()`, which the layers of a + * stack share): the parameters of a route, or `null`. The number of the + * segments is compared first. + */ +export function matchRoute( + pattern: IPattern, + path: readonly string[] | string, + { + case_sensitive = true, + strict = true, + encoded_slashes = false, + }: IMatchOptions, +): IParams | null { + const parts = typeof path === "string" ? split(path) : path; + // One trailing `/` of the path, or of the pattern, may be missing + const segments = strict ? pattern.segments : pattern.loose; + const count = + !strict && parts.length > 1 && parts.at(-1) === "" + ? parts.length - 1 + : parts.length; + if ( + pattern.wildcard === null + ? count !== segments.length + : count < segments.length + ) { + return null; + } + 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, count) + .map((part) => value(part, encoded_slashes)) + .join("/"); + return { ...params, ...Object.fromEntries([[pattern.wildcard, rest]]) }; + } + return params; +} + +/** + * Matches the leading segments of a path (or its segments, see `split()`) on a + * segment boundary: the parameters, the matched part and the rest of the + * path, or `null`. The number of the segments is compared first. + */ +export function matchPrefix( + pattern: IPattern, + path: readonly string[] | string, + { case_sensitive = true, encoded_slashes = false }: IMatchOptions, +): IPrefixMatch | null { + const parts = typeof path === "string" ? split(path) : path; + const { length } = pattern.segments; + if (parts.length < length) { + return null; + } + const params = leading( + pattern.segments, + parts, + case_sensitive, + encoded_slashes, + ); + 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 new file mode 100644 index 00000000..e07ca650 --- /dev/null +++ b/packages/binden/src/router.ts @@ -0,0 +1,218 @@ +import { type IMiddleware, Middleware } from "./middleware.js"; +import { described, formatting, objectOf } from "./formatting.js"; +import type { IParams } from "./context.js"; +import { formatStack } from "./exchange.js"; + +/** The options of a `Router` */ +export interface IRouterOptions { + /** + * 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; +} + +/** A method: an HTTP token (RFC 9110, section 9.1) */ +const token = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; + +/** The `guarded` option, read once, with its default */ +function guardedOf({ guarded = false }: IRouterOptions): unknown { + return guarded; +} + +/** 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; + + /** + * A router without middlewares: a `guarded` that is not a boolean throws a + * `TypeError`, and so do options that are not an object. What reading them + * throws (e.g. a revoked proxy) is formatted (see `formatting()`). + */ + public constructor(options?: IRouterOptions) { + const guarded = formatting(() => + guardedOf(objectOf(options, "options", true) as IRouterOptions), + ); + if (typeof guarded !== "boolean") { + throw formatStack(new TypeError("`guarded` is not a boolean")); + } + this.#guarded = guarded; + 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); + } + + /** Get all supported methods by the router */ + public get methods(): Set { + const methods = new Set(); + + for (const [method, middlewares] of this.#middlewares) { + if (middlewares.find((m) => typeof m === "function" || !m.disabled)) { + methods.add(method); + } + } + + return methods; + } + + /** + * The middlewares of a method (in any case): a method that is not a string + * throws a `TypeError`, which describes it without converting it (see + * `described()`) + */ + public get middlewares(): (method: string) => IMiddleware[] { + return (method: string): IMiddleware[] => { + if (typeof method !== "string") { + throw formatStack( + new TypeError(`The method is not a string: ${described(method)}`), + ); + } + return [...(this.#middlewares.get(method.toUpperCase()) ?? [])]; + }; + } + + /** + * Adds middlewares for a method (in any case: `get` is `GET`): a method that + * is not a token, or a value that is not a middleware throws a `TypeError` + * (what checking it throws, e.g. of a revoked proxy, is formatted, see + * `formatting()`) + */ + public on( + _method: string, + ..._middlewares: IMiddleware[] + ): this { + const method = Router.#method(_method); + if (!_middlewares.length) { + return this; + } + + formatting(() => { + for (const middleware of _middlewares) { + if (!( + middleware instanceof Middleware || typeof middleware === "function" + )) { + throw formatStack(new TypeError("Middleware is not supported")); + } + } + }); + + const middlewares = this.#middlewares.get(method); + + if (middlewares) { + middlewares.push(..._middlewares); + } else { + this.#middlewares.set(method, [..._middlewares]); + } + + return this; + } + + /** 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[] = []; + + if (!middlewares.length) { + return removed; + } + + for (const middleware of _middlewares) { + const index = middlewares.lastIndexOf(middleware); + + if (index !== -1) { + removed.push(...middlewares.splice(index, 1)); + + if (!middlewares.length) { + this.#middlewares.delete(method); + return removed; + } + } + } + + 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); + } + + /** + * 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 #method(method: unknown): string { + if (typeof method !== "string" || !token.test(method)) { + throw formatStack( + new TypeError(`Method ${described(method)} is not a valid token`), + ); + } + const upper = method.toUpperCase(); + if (upper === "CONNECT") { + throw formatStack(new TypeError("Method CONNECT is not supported")); + } + return upper; + } +} diff --git a/packages/binden/src/seal.ts b/packages/binden/src/seal.ts new file mode 100644 index 00000000..52cfc298 --- /dev/null +++ b/packages/binden/src/seal.ts @@ -0,0 +1,357 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +/** + * The owner of the current asynchronous work: the token of the work of a + * request that has started it (its middleware stack, or a run of its error + * handler, see `Exchange.token()`), `undefined` for a work of no request. + * Not part of the public API. + * + * @internal + */ +export const owners = new AsyncLocalStorage(); + +/** + * The key of the internal method that seals a response: + * `response[kSeal](allows)` lets only the owners that `allows(owner)` accepts + * mutate it (none without it, or when it throws). Not part of the public API. + * + * @internal + */ +export const kSeal: unique symbol = Symbol("binden.seal"); + +/** + * The key of the internal method that cancels the pending sends of a response + * whose headers are not committed yet: `response[kCancel](reason)` (see + * `BindenResponse`). Not part of the public API. + * + * @internal + */ +export const kCancel: unique symbol = Symbol("binden.cancel"); + +/** + * The key of the internal method that records at once the cancellation of + * the pending sends of a response whose headers are not committed yet, which + * `[kCancel]()` carries out later (e.g. once the works that it abandons have + * been told, see `kRevoke`): `response[kCancelling](reason)`. A send that + * settles in between rejects with the reason. Not part of the public API. + * + * @internal + */ +export const kCancelling: unique symbol = Symbol("binden.cancelling"); + +/** + * The key of the internal method that runs a function of the framework with + * the rights of the framework, whoever owns the response: + * `response[kFramework](() => response.destroy())`. Not part of the public API. + * + * @internal + */ +export const kFramework: unique symbol = Symbol("binden.framework"); + +/** + * The reports of the cookies that fail as the head of a response is + * committed, by response, once the framework replies an error (see + * `Binden`): a response that has one serialises each cookie in its own + * guard, and drops and reports one that fails (see `cookies` of + * `BindenResponse`). Not part of the public API. + * + * @internal + */ +export const cookieGuards = new WeakMap void>(); + +/** + * The key of the internal method that adds an observer of an event of a + * response for the framework (or a library of binden), which runs once by + * default: `response[kListen](event, listener, { once })` returns what + * removes it (see also `kUnlisten`). It is not a listener of the response, + * which the application may remove (e.g. `removeAllListeners()`): it runs + * from `emit()` after the listeners (also when one of them throws, which is + * then thrown), never before the event (see `kRevoke`), as the work that has + * added it (without the rights of the framework), with the response as its + * receiver. An `error` that observers listen to is not thrown without a + * listener. Not part of the public API. + * + * @internal + */ +export const kListen: unique symbol = Symbol("binden.listen"); + +/** + * The key of the internal method that removes an observer that + * `[kListen]()` has added (the last one of the listener): + * `response[kUnlisten](event, listener)`. Not part of the public API. + * + * @internal + */ +export const kUnlisten: unique symbol = Symbol("binden.unlisten"); + +/** + * The key of the internal method that fails a pipe of a work of the exchange + * of a response, from the failure handler that the exchange has set for the + * work (see `onPipeFailure()`): `response[kPipeFailure](owner, error, + * source)`, `true` once the framework handles it (see `IOwnership.fail()`). + * Not part of the public API. + * + * @internal + */ +export const kPipeFailure: unique symbol = Symbol("binden.pipe_failure"); + +/** + * A failure of a pipe of a work (see `IOwnership.fail()`): the work that it + * belongs to, and its error + * + * @internal + */ +export interface IPipeFailure { + /** The work whose pipe has failed (its token) */ + readonly owner: object; + /** The error of the pipe (e.g. of the write of its source) */ + readonly error: unknown; +} + +/** + * The key of the internal method that tells a response that works may no + * longer mutate it: `response[kRevoke](owners, failure)`. At once, their + * mutations are ignored; on the next tick, the `error` of the failure (to its + * owner only) then `close` are delivered to the listeners that each work has + * added to the response (and only to them: the response is closed for the + * abandoned work), and its sources still piped are unpiped. Resolves once + * delivered. Not part of the public API. + * + * @internal + */ +export const kRevoke: unique symbol = Symbol("binden.revoke"); + +/** + * How the framework handles the works that own a response (see + * `kOwnership`). Not part of the public API. + * + * @internal + */ +export interface IOwnership { + /** + * A pipe of a work into the response has failed (e.g. a write of its source + * has thrown for a head that Node.js refuses): `true` once the framework + * handles it, `false` for a work that it does not know (e.g. of no request), + * which is left to Node.js. Called synchronously: the work loses its rights + * at once (see `kRevoke`). + */ + fail(owner: object, error: unknown): boolean; + /** + * Whether a source piped by the work fails it when it errors or closes + * before its end (e.g. the work of an error handler, which Node.js would + * leave waiting) + */ + guards(owner: object): boolean; + /** + * The work that replies now (the token of the latest reply of the + * framework, e.g. a run of the error handler), whose adopted pipe a + * destroy of no work fails (see `BindenResponse.destroy()`): `null` + * without one, or once the connection terminates (it has closed, or the + * server shuts down) + */ + replier(): object | null; + /** + * Whether a pipe of the work whose source has listener methods of its own + * (e.g. bound by OpenTelemetry), which cannot be adopted, is left to + * Node.js rather than refused: a work whose rights do not matter until it + * is revoked (the stack), whose pipe is dismantled then, and the response + * closed unless it has ended (see `BindenResponse`) + */ + native(owner: object): boolean; + /** Logs a failure that nothing else handles (e.g. of an abandoned work) */ + debug(message: string, error: unknown): void; + /** + * Logs a refused pipe of a work that it does not know (e.g. of no + * request), with the reason: the response is left to its owner, and a + * pipe that cannot be unpiped (e.g. a legacy one) to Node.js + */ + warn(message: string, reason: string): void; + /** Logs (at `debug`) what is done with a pipe or a sent stream, with the reason */ + note(message: string, reason: string): void; + /** + * Logs (at `debug`, with `releasedFailure`) a late error of a source that + * is not sent, or no longer (e.g. its `destroy()` fails asynchronously): a + * Node.js `Readable`, a stream-like source, or the request of the response + * once a send of it (an echo) no longer listens to it (e.g. its abort, once + * the client has gone away), which is never destroyed. A function of its + * own scope, which holds neither the request nor the response, since the + * source may keep it as long as it lives (none: not reported). Given a + * message, it logs the failure with that one instead (e.g. a removal of a + * listener of the guard of the source that throws, which is not a late + * error) + */ + readonly released?: ((error: unknown, message?: string) => void) | undefined; +} + +/** + * The message of a late error of a stream that a send has released (see + * `IOwnership.released`): one neutral message, whatever the stream (a + * Node.js `Readable`, a stream-like source, or the request of the response, + * which is never destroyed). Not part of the public API. + * + * @internal + */ +export const releasedFailure = "A released stream has failed"; + +/** + * The key of the internal method that sets how the framework handles the + * works that own a response: `response[kOwnership](ownership)`. Not part of + * the public API. + * + * @internal + */ +export const kOwnership: unique symbol = Symbol("binden.ownership"); + +/** + * The key of the method of `BindenResponse` that starts an operation of a + * library that writes to the response (e.g. the compressor of + * `@binden/compression`): see `IResponseOperation`. For the packages of + * binden only. + * + * @internal + */ +export const kOperation: unique symbol = Symbol("binden.operation"); + +/** + * An operation on a response (e.g. a compressor): it belongs to the owner of + * the work that has started it, whose rights it keeps in its asynchronous + * callbacks. For the packages of binden only. + * + * @internal + */ +export interface IResponseOperation { + /** Whether the operation may still mutate the response */ + readonly active: boolean; + /** + * Runs a callback of the operation (e.g. of its stream) as its owner: the + * response accepts its mutations as long as the operation is `active` + */ + run(fn: () => T): T; + /** + * The response has been ended by the operation: the transfer of what it has + * accepted continues (through `run()`) even once its owner may no longer + * mutate the response + */ + transfer(): void; +} + +/** + * The key of the method of `BindenResponse` that checks the arguments of + * `writeHead()` as Node.js does, without changing the response, and returns + * them as Node.js gets them, each read once, converted no more than Node.js + * converts it (see `IHead`): `response[kValidateHead](statusCode, reason, + * headers)` throws the error that `writeHead()` would throw for the status + * code, for the reason phrase that it would send (the one given, or the one + * of the response) or for a header given, after it had changed the response, + * and the error of binden for `Content-Length` values (the ones given, or + * else the ones set) of another type than a number or a string, never + * converted, or that disagree. A library that takes a decision at the commit + * of the headers (e.g. `@binden/compression`) checks them first, and commits + * the head with what it returns. For the packages of binden only. + * + * @internal + */ +export const kValidateHead: unique symbol = Symbol("binden.validate_head"); + +/** + * The key of the method of `BindenResponse` that chooses the representation + * of the body of a response before the preconditions of the request are + * evaluated: `response[kRepresentation](length)` (see `IRepresentation`), + * called by `sendFile()` and by the dynamic `ETag` of `send()`. For the + * packages of binden only. + * + * @internal + */ +export const kRepresentation: unique symbol = Symbol("binden.representation"); + +/** + * The key of the method of `BindenResponse` that lets a library (e.g. + * `@binden/compression`) choose the representation of the body of a + * response: `response[kNegotiator](negotiator)` (see + * `IRepresentationNegotiator`). For the packages of binden only. + * + * @internal + */ +export const kNegotiator: unique symbol = Symbol("binden.negotiator"); + +/** + * The key of the method of `BindenResponse` by which a library that has + * replaced the response (e.g. the empty `406` of `@binden/compression`) + * reports that it discards the write (or the `end()`) that it is called in: + * `response[kDiscardWrite]()`. The bytes of that write are no part of the + * response, whose head frames a body of its own: a send whose bytes are gone + * once that write has returned (e.g. detached by code of the application) is + * not cut for them. Every other write of a send is checked (also one that a + * writer has given to Node.js by `end()`, or that a compressor has accepted), + * and cut when they are gone. For the packages of binden only. + * + * @internal + */ +export const kDiscardWrite: unique symbol = Symbol("binden.discard_write"); + +/** + * The representation of the body of a response, as it will be sent. For the + * packages of binden only. + * + * @internal + */ +export interface IRepresentation { + /** + * The content coding that a library applies to the body as it is sent + * (e.g. `gzip`): its `ETag` is then weak, and it has no ranges. `null` when + * the body is sent as it is. + */ + readonly coding: string | null; + /** + * The status of the empty response that replaces the response (e.g. `406 + * Not Acceptable`), whatever its preconditions: `null` when it is sent + */ + readonly status: number | null; +} + +/** + * How a library chooses the representation of the body of a response (see + * `kNegotiator`). For the packages of binden only. + * + * @internal + */ +export interface IRepresentationNegotiator { + /** + * The representation of a body of `length` bytes (`null` when it is not + * known), chosen before the headers are committed: the choice sends no + * header and starts no coding, and the commit of the headers applies it + */ + choose(length: number | null): IRepresentation; + /** + * The body that the representation has been chosen for is not sent (e.g. + * its stream has failed before its first chunk): the commit of another + * reply chooses by itself + */ + forget(): void; +} + +/** + * The headers of a body (its framing, representation and validators), which + * do not apply to a reply that replaces it: the error replies of binden, and + * the empty replacements of a library (e.g. the `406` of + * `@binden/compression`). For the packages of binden only. + * + * @internal + */ +export const bodyHeaders: readonly string[] = Object.freeze([ + "Content-Digest", + "Content-Disposition", + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-Range", + "Content-Type", + "Digest", + "ETag", + "Last-Modified", + "Repr-Digest", + // The trailers of a chunked body: Node.js refuses them for another body + "Trailer", + "Transfer-Encoding", +]); diff --git a/packages/binden/src/send-file.ts b/packages/binden/src/send-file.ts new file mode 100644 index 00000000..71d56317 --- /dev/null +++ b/packages/binden/src/send-file.ts @@ -0,0 +1,742 @@ +import { type FileHandle, lstat, open, realpath } from "node:fs/promises"; +import { type IRepresentation, kRepresentation } from "./seal.js"; +import { contentDigests, representationDigests } from "./digests.js"; +import { described, formatting } from "./formatting.js"; +import native, { type PlatformPath, join } from "node:path"; +import { BindenError } from "./error.js"; +import { BindenRequest } from "./request.js"; +import type { BindenResponse } from "./response.js"; +import ContentRange from "./headers/content-range.js"; +import ContentType from "./headers/content-type.js"; +import IfModifiedSince from "./headers/if-modified-since.js"; +import type { IncomingMessage } from "node:http"; +import Range from "./headers/range.js"; +import type { Readable } from "node:stream"; +import { constants } from "node:fs"; +import { contentTypeOf } from "./mime.js"; +import { fileURLToPath } from "node:url"; +import { formatStack } from "./exchange.js"; +import { parseHttpDate } from "./headers/http-date.js"; +import { split } from "./headers/list.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 regular file (e.g. a socket, + * which cannot be opened: `ENXIO`, or `EOPNOTSUPP` on macOS) + */ +const missing: ReadonlySet = new Set([ + "EISDIR", + "ELOOP", + "ENAMETOOLONG", + "ENODEV", + "ENOENT", + "ENOTDIR", + "ENXIO", + "EOPNOTSUPP", +]); + +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 formatStack(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) { + const { protocol } = root; + if (protocol !== "file:") { + throw formatStack( + new TypeError(`Protocol ${described(protocol)} is not supported`), + ); + } + return fileURLToPath(root); + } else if (typeof root !== "string" || root === "") { + throw formatStack( + 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 formatStack( + new TypeError("`dotfiles` is not one of allow, deny and ignore"), + ); + } else if ( + typeof validator !== "undefined" && + // A string before the regular expression, which would convert it + (typeof validator !== "string" || !strongTag.test(validator)) + ) { + throw formatStack(new TypeError("`validator` is not a strong entity-tag")); + } else if ( + typeof cache_control !== "undefined" && + (typeof max_age !== "undefined" || typeof immutable !== "undefined") + ) { + throw formatStack( + new TypeError("`cache_control` excludes `max_age` and `immutable`"), + ); + } else if ( + typeof cache_control !== "undefined" && + (typeof cache_control !== "string" || !fieldValue.test(cache_control)) + ) { + throw formatStack( + new TypeError("`cache_control` is not a valid field value"), + ); + } else if (typeof max_age !== "undefined" && !Number.isSafeInteger(max_age)) { + throw formatStack(new TypeError("`max_age` is not an integer")); + } else if (typeof max_age !== "undefined" && max_age < 0) { + throw formatStack(new RangeError("`max_age` is negative")); + } else if ( + typeof immutable !== "undefined" && + typeof max_age === "undefined" + ) { + throw formatStack(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 formatStack(new TypeError(`Invalid media type: ${content_type}`)); + } + type = parsed.toString(); + } else if (content_type === false) { + type = false; + } else if (typeof content_type !== "undefined") { + throw formatStack( + 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 a trusted path (without `root`): a path, or a `file:` URL, + * which is copied. What reading it throws (e.g. a revoked proxy, or a getter + * of a subclass of `URL`) is formatted (see `formatting()`). + */ +function trusted(path: unknown): ILocation { + return formatting(() => { + if (path instanceof URL) { + const { protocol } = path; + if (protocol !== "file:") { + throw formatStack( + new TypeError(`Protocol ${described(protocol)} is not supported`), + ); + } + return { + file: new URL(path.href), + name: path.pathname, + contained: false, + }; + } else if (typeof path !== "string") { + throw formatStack(new TypeError("The path is not a string or a URL")); + } + return { file: path, name: path, contained: false }; + }); +} + +/** + * The file of `path`: as it is without `root` (a trusted path, or a `file:` + * URL, see `trusted()`), otherwise the real path of a relative path that stays + * inside the real path of `root` (404 outside of it, for a symbolic link that + * leads out, or for anything but a regular file: a socket, a device or a FIFO + * is not opened) + */ +async function locate( + path: URL | string, + { root, dotfiles }: IFileOptions, +): Promise { + if (root === null) { + return trusted(path); + } else if (typeof path !== "string") { + throw formatStack( + 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); + const stats = await lstat(file).catch((error: unknown) => { + throw notFound(error); + }); + if (!stats.isFile()) { + throw new BindenError(404); + } + 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 `ETag` of a representation (`null` without one): a strong one becomes + * weak when a library codes the body as it is sent, as it no longer stands + * for the bytes that are sent (RFC 9110, section 8.8.3.3) + */ +export function tagOf( + etag: unknown, + { coding }: IRepresentation, +): string | null { + if (typeof etag !== "string") { + return null; + } + return coding === null || etag.startsWith("W/") ? etag : `W/${etag}`; +} + +/** + * The methods that neither select nor modify a representation, whose + * conditional header fields are ignored (RFC 9110, section 13.2.1) + */ +const unconditional: ReadonlySet = new Set([ + "CONNECT", + "OPTIONS", + "TRACE", +]); + +/** + * The status of a request whose precondition fails for a representation, in + * the order of RFC 9110 (section 13.2.2), or `null` when they pass: + * `If-Match` (or else `If-Unmodified-Since`) fails with `412`, then + * `If-None-Match` (or else `If-Modified-Since`, for `GET` and `HEAD`) matches + * with `304` for `GET` and `HEAD`, and `412` for the other methods. The + * entity-tags are compared with the `ETag` of the representation (`null` + * without one), the dates with its `Last-Modified` (ignored when `null`). + * They are ignored for `CONNECT`, `OPTIONS` and `TRACE` (section 13.2.1). + * Shared by `sendFile()` and the dynamic `ETag` of the responses. + */ +export function preconditionsOf( + request: IncomingMessage, + etag: string | null, + lm: Date | null, +): 304 | 412 | null { + const { method = "GET", headers } = request; + if (unconditional.has(method)) { + return null; + } + const read = method === "GET" || method === "HEAD"; + const ims = + request instanceof BindenRequest + ? request.if_modified_since + : IfModifiedSince.fromString(headers["if-modified-since"]); + 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, etag, true)) { + return 412; + } + } else if (lm !== null && ius !== null && lm > ius) { + return 412; + } + if (typeof ifNoneMatch === "string") { + if (matches(ifNoneMatch, etag, 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 }; +} + +/** Removes the headers of a response that are set */ +function remove(response: BindenResponse, names: readonly string[]): void { + for (const name of names) { + if (response.hasHeader(name)) { + response.removeHeader(name); + } + } +} + +/** 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", + }); +} + +/** + * Closes the file of a stream that its send has settled without reading + * (e.g. a send that is ignored, or that has failed before it): a failure to + * close it is of interest to nobody + */ +function release(stream: Readable): void { + if (!stream.destroyed) { + stream + .on("error", () => { + // Nobody waits for it + }) + .destroy(); + } +} + +/** + * 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. A `304` is + * sent by `notModified()`, with the length of the whole file. The file is the + * body of the status of the response: the preconditions apply to a `2xx` (or a + * `412`), the ranges to a `200`. Once `stopped()` (the send has been + * cancelled, or may no longer be sent), the file is not opened, or is closed + * at once: it is checked after every step that is awaited, and nothing is + * awaited between its last check and the send of the stream of the file, which + * is destroyed (closing the file) when that send settles without reading it. + * Once the headers of the file are set, a failure (e.g. a send that refuses + * the status, or a head that Node.js refuses) calls `unwind()` before it + * rejects, which restores the response as it was before the file. + */ +export async function sendFileTo( + response: BindenResponse, + path: URL | string, + options: ISendFileOptions | undefined, + notModified: (length: number) => Promise, + stopped: () => boolean, + unwind: () => void, +): Promise { + // The status that the application has set (e.g. `404` for a page of an + // error): the file is its body + const status = response.statusCode; + // What reading the options throws (e.g. a revoked proxy, or a getter that + // throws) is formatted (see `formatting()`), as what reading the path + // throws (see `trusted()`) + const parsed = formatting(() => parseOptions(options ?? {})); + const location = await locate(path, parsed); + if (stopped()) { + return; + } + const handle = await openFile(location); + // Closed by the stream of its content, once created, or else here + let streamed = false; + // Whether the headers of the file may have been set (see `unwind()`) + let set = false; + try { + if (stopped()) { + // Opened late: nothing is sent + return; + } + const stats = await handle.stat(); + if (stopped()) { + // Stopped while its metadata was read: closed at once. The last check: + // nothing is awaited from here until its stream is sent + return; + } + + if (!stats.isFile()) { + throw location.contained + ? new BindenError(404) + : formatStack( + 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; + + set = true; + setHeaders(response, parsed, location.name, lm, stats); + + // The representation, before the preconditions (RFC 9110, section + // 13.2.2): a library may code the file as it is sent (e.g. a compression: + // its `ETag` is then weak, and it has no ranges), or replace the response + // (e.g. with `406 Not Acceptable`, whatever the preconditions are) + const representation = response[kRepresentation](size); + if (representation.status !== null) { + // Without the digests and the trailers (which Node.js refuses without + // chunks) of the representation that it replaces + remove(response, [ + "Trailer", + ...contentDigests, + ...representationDigests, + ]); + await response.status(representation.status).send(); + return; + } + + const { req } = response; + const { method = "GET", headers } = req; + const etag = response.getHeader("ETag"); + // The preconditions apply to a successful response (or a `412`), not to + // a redirect or an error (RFC 9110, section 13.2.1) + const failed = + (status >= 200 && status <= 299) || status === 412 + ? preconditionsOf(req, tagOf(etag, representation), lm) + : null; + if (failed === 304) { + // With the length of the whole file, as the `200` that it stands for + await notModified(size); + return; + } else if (failed !== null) { + // Empty, also for `HEAD` (which would keep the length of a body), and + // without the coding, the digests (e.g. of a precompressed file) nor + // the trailers of the file, whose representation it replaces + remove(response, [ + "Content-Encoding", + "Trailer", + ...contentDigests, + ...representationDigests, + ]); + await response.status(failed).setHeader("Content-Length", 0).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), of a response that + // would be a `200` (not to any other status, e.g. of an error), and to + // the file as it is: a coded representation is sent whole + const ranges = + method !== "GET" || + status !== 200 || + representation.coding !== null || + !ifRange(headers["if-range"], etag, lm) + ? [] + : req instanceof BindenRequest + ? req.range + : Range.fromString(headers.range); + const range = byteRange(ranges, size); + + if (range === "unsatisfiable") { + // Empty: without the digests nor the trailers of the file + remove(response, [ + "Trailer", + ...contentDigests, + ...representationDigests, + ]); + 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 }; + if (range === null) { + response.setHeader("Content-Length", size); + } else { + // A part of the file: the digests of its representation, but not the + // digest of the whole content + remove(response, contentDigests); + const cr = new ContentRange({ size, start, end }).toString(); + response + .set({ "Content-Length": end - start + 1, "Content-Range": cr }) + .status(206); + } + // Created only to be sent at once + const stream = handle.createReadStream({ start, end }); + streamed = true; + await response.send(stream).finally(() => { + release(stream); + }); + } catch (error: unknown) { + if (set) { + unwind(); + } + throw error; + } finally { + if (!streamed) { + await handle.close(); + } + } +} diff --git a/packages/binden/src/shutdown.ts b/packages/binden/src/shutdown.ts new file mode 100644 index 00000000..be02c2b2 --- /dev/null +++ b/packages/binden/src/shutdown.ts @@ -0,0 +1,367 @@ +import { type EventEmitter, errorMonitor } from "node:events"; +import { type Exchange, formatStack } from "./exchange.js"; +import { formatting, objectOf } from "./formatting.js"; +import { kFramework, kListen } from "./seal.js"; +import type { BindenResponse } from "./response.js"; +import type { Server as HttpServer } from "node:http"; +import type { Server as HttpsServer } from "node:https"; +import type { Socket } from "node:net"; +import { validateDelay } from "./delay.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; + /** + * Whether a `listen()` has not completed yet (e.g. while the address of a + * host name is looked up) + */ + pending: boolean; + readonly sockets: Set; + /** The requests in progress, with their response */ + readonly exchanges: Map; + /** The newest response of every connection (with the latest request) */ + readonly newest: WeakMap; + /** + * The responses that close their connection because the server is closing, + * with their own `shouldKeepAlive` + */ + readonly kept: WeakMap; + /** 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, + pending: false, + sockets: new Set(), + exchanges: new Map(), + newest: new WeakMap(), + kept: new WeakMap(), + drained: [], + }; + registrations.set(server, registration); + const listen = server.listen.bind(server); + server.listen = ((...args: unknown[]): IServer => { + // What Node.js throws for its arguments (e.g. a port that is not one, or + // a revoked proxy) is formatted, as by every entry point of binden (see + // `formatting()`); its coercions (e.g. of a host or a backlog) stay + try { + Reflect.apply(listen, server, args); + } catch (error: unknown) { + throw formatStack(error as Error); + } + // Bound later, e.g. once the address of a host name has been looked up + // (a `listen()` that throws is not pending) + registration.pending = !server.listening; + return server; + }) as typeof server.listen; + const settled = (): void => { + registration.pending = false; + }; + // `errorMonitor`: an `error` without a listener of the application still + // throws, as it would without this one + server.on("listening", settled).on("close", settled); + (server as EventEmitter).on(errorMonitor, settled); + 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; +} + +/** + * Makes a response close its connection once it is complete (`Connection: + * close`), unless its headers have been sent: decided by Node.js when they + * are, so that it applies to the newest response of the connection then + */ +function closesConnection( + registration: IRegistration, + response: BindenResponse, +): void { + if (!response.headersSent && !registration.kept.has(response)) { + registration.kept.set(response, response.shouldKeepAlive); + // Also once the response is sealed (e.g. for the reply of a deadline) + response[kFramework](() => { + response.shouldKeepAlive = false; + }); + } +} + +/** + * 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: BindenResponse, +): void { + const registration = register(server); + registration.exchanges.set(exchange, response); + // The requests of a connection are emitted in their order: once the server + // is closing, its newest one closes it, so that the pipelined requests + // before it get their responses + const { socket } = response.req; + const previous = registration.newest.get(socket); + registration.newest.set(socket, response); + if ( + typeof previous !== "undefined" && + registration.kept.has(previous) && + !previous.headersSent + ) { + // A request that arrives once closing (its `503` closes the connection): + // the response before it, unless its headers have been sent, keeps the + // connection open for it + const kept = registration.kept.get(previous) as boolean; + previous[kFramework](() => { + previous.shouldKeepAlive = kept; + }); + registration.kept.delete(previous); + } + response[kListen]("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; +} + +/** + * Settles once a pending `listen()` has completed: `null` once the server is + * listening (or closed), or the error of a `listen()` that has failed (which + * the shutdown reports). A `listen()` that has not completed within `timeout` + * milliseconds is cancelled, with an error: the server is closed, so that an + * address looked up later is not bound (Node.js ignores it). Its listeners are + * removed then. + */ +async function bound( + server: IServer, + timeout: number, +): Promise<{ error: unknown } | null> { + const { promise, resolve } = Promise.withResolvers<{ + error: unknown; + } | null>(); + const settled = (): void => { + resolve(null); + }; + const failed = (error: unknown): void => { + resolve({ error }); + }; + // What a value that is not a server throws, here and once awaited, is + // formatted (see `formatting()`): the shutdown rejects with it + formatting(() => + server + .once("listening", settled) + .once("close", settled) + .once("error", failed), + ); + const timer = setTimeout(() => { + const message = `The server has not started listening within ${timeout} ms`; + resolve({ error: formatStack(new Error(message)) }); + server.close(); + }, timeout); + try { + return await promise; + } finally { + clearTimeout(timer); + formatting(() => + server + .off("listening", settled) + .off("close", settled) + .off("error", failed), + ); + } +} + +/** + * Closes the server and its connections: those that are idle at once, the + * others once their responses are complete or `timeout` has passed + */ +async function drain( + server: IServer, + registration: IRegistration, + timeout: number, +): Promise { + // Before `listen()` (or once closed), there is nothing to close but the + // connections that are left. What a value that is not a server throws is + // formatted (see `formatting()`): the shutdown rejects with it. + const closed = formatting(() => { + const listening = 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(); + return listening; + }); + + 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 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 (registration.newest.get(response.req.socket) === response) { + closesConnection(registration, response); + } + } + // A server that is about to listen is closed once it listens, but not + // waited for when its `listen()` fails (or takes longer than `timeout`): + // its error rejects the shutdown. The `timeout` covers both waits. + const start = performance.now(); + const failure = registration.pending ? await bound(server, timeout) : null; + if (failure === null) { + const left = Math.max(0, timeout - (performance.now() - start)); + await drain(server, registration, left); + } + + // 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); + const failed = done + ? failure + : { + error: formatStack( + new Error( + `The cleanup of the requests has not completed within ${cleanup_timeout} ms`, + ), + ), + }; + if (failed !== null) { + // The report of an application does not throw + registration.report?.("The shutdown has failed", failed.error); + throw failed.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. The server must be registered (see `Binden.callback()`) before its + * `listen()`: a `listen()` in progress then is closed once it listens, or + * cancelled (with an error) once `timeout` has passed. Options that are not + * an object throw a `TypeError`, and what reading them or the server throws + * (e.g. a revoked proxy) is formatted (see `formatting()`), also once the + * shutdown rejects with it. + */ +export function shutdown( + server: IServer, + options?: IShutdownOptions, +): Promise { + return formatting(() => { + const { timeout = 10_000, cleanup_timeout = 1000 } = objectOf( + options, + "options", + true, + ) as IShutdownOptions; + 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/src/stack.ts b/packages/binden/src/stack.ts new file mode 100644 index 00000000..99346205 --- /dev/null +++ b/packages/binden/src/stack.ts @@ -0,0 +1,341 @@ +import { type IMiddleware, Middleware } from "./middleware.js"; +import { type IPattern, type IRouteParams, compile } from "./route.js"; +import type { IParams } from "./context.js"; +import { Router } from "./router.js"; +import { formatStack } from "./exchange.js"; +import { formatting } from "./formatting.js"; +import { isDeepStrictEqual } from "node:util"; + +/** 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 + | { + /** 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 */ +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. What its methods throw while they check their + * arguments (e.g. for an item that a revoked proxy is) is formatted (see + * `formatting()`). + */ +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 (at least one) for every path; a stack is + * added with `mount()` + */ + public use(...items: (IStackItem | IUntypedItem)[]): this; + /** + * Adds middlewares and routers for an exact path (a string) or for the + * paths that a RegExp matches + */ + public use( + path: RegExp | string, + ...items: (IStackItem | IUntypedItem)[] + ): this; + public use(_path: unknown, ...rest: unknown[]): this { + return formatting(() => { + const first = isItem(_path); + const items = first ? [_path, ...rest] : rest; + // A stack (for `mount()`) or `undefined` taken as a path would leave the + // call silently ignored, or add its items for every path + if ( + !items.length || + (!first && typeof _path !== "string" && !(_path instanceof RegExp)) + ) { + throw formatStack(new TypeError("Unsupported Middleware/Router")); + } + for (const item of items) { + if (!isItem(item)) { + throw formatStack(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 { + return formatting(() => { + const compiled = compile(pattern, "route"); + if (!items.length) { + return this; + } + for (const item of items) { + if (!isItem(item)) { + throw formatStack(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 { + return formatting(() => { + const compiled = compile(prefix, "mount"); + if (!items.length) { + return this; + } + for (const item of items) { + if (item instanceof Stack) { + if (roots.has(item)) { + throw formatStack( + new TypeError("An application (`Binden`) cannot be mounted"), + ); + } else if (item === this || Stack.#reaches(item, this)) { + throw formatStack( + new TypeError("A stack cannot be mounted in itself"), + ); + } + } else if (!isItem(item)) { + throw formatStack( + 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()` 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)[] + ): IStackItem[]; + public off(_path: unknown, ...rest: unknown[]): IStackItem[] { + return formatting(() => { + 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/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/src/stream-like.ts b/packages/binden/src/stream-like.ts new file mode 100644 index 00000000..10cd502d --- /dev/null +++ b/packages/binden/src/stream-like.ts @@ -0,0 +1,1424 @@ +import { carrierOf, formatStack, readsSafely } from "./exchange.js"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; + +/** A listener of an event of a stream-like source (see `IStreamLike`) */ +export type IStreamLikeListener = (...args: unknown[]) => unknown; + +/** + * What `send()` calls on a stream-like source (see `IStreamLike`): its + * events, and its flow for the backpressure + */ +export interface IStreamLikeSource { + /** Adds a listener of its `data`, `end`, `error` or `close` */ + on(event: string, listener: IStreamLikeListener): unknown; + /** Stops its `data` while the response is full */ + pause(): unknown; + /** Resumes its `data` */ + resume(): unknown; + /** + * Destroys it once the send is cancelled (e.g. at the deadline), with the + * reason: without it, the source is paused instead + */ + destroy?(error?: Error): unknown; +} + +/** A stream-like source whose listeners are removed by `removeListener()` */ +export interface IStreamLikeRemovable extends IStreamLikeSource { + /** Removes a listener that `send()` has added */ + removeListener(event: string, listener: IStreamLikeListener): unknown; +} + +/** A stream-like source whose listeners are removed by `off()` */ +export interface IStreamLikeOff extends IStreamLikeSource { + /** Removes a listener that `send()` has added */ + off(event: string, listener: IStreamLikeListener): unknown; +} + +/** + * A stream that is not a Node.js `Readable` (e.g. of `readable-stream`, or of + * another realm), which `send()` reads as one: an object with `on()`, + * `pause()`, `resume()`, and `removeListener()` or `off()` + */ +export type IStreamLike = IStreamLikeOff | IStreamLikeRemovable; + +/** A method of an object, called with the object as its receiver */ +type IMethod = (...args: unknown[]) => unknown; + +/** The methods of a stream-like source, as they were when it was sent */ +export interface IStreamLikeMethods { + readonly on: IMethod; + readonly pause: IMethod; + readonly resume: IMethod; + readonly remove: IMethod; + readonly destroy: IMethod | null; +} + +/** + * How a late error of a source that is not sent, or no longer, is reported + * (see `guard()`), or, with a `message` of its own, another failure that the + * guard of the source logs (e.g. a removal of one of its listeners that + * throws, see `removalFailure`), which is not a late error: a function that + * holds neither the request nor the response, since a source may keep it as + * long as it lives + */ +export type ISourceReport = (error: unknown, message?: string) => void; + +/** + * The message of a removal of a listener of a stream that throws, as binden + * logs it for a Node.js `Readable` (see `BindenResponse`), also for one of + * the listeners of a guard (see `guard()`), which is not a late error + */ +const removalFailure = "The removal of a listener of a stream has failed"; + +/** + * How a response logs (`debug`) what code of a source has thrown while it is + * released (see `IStreamLikeLogs`). For the packages of binden only. + * + * @internal + */ +export type IStreamLikeLog = (error: unknown) => void; + +/** + * How code of a stream-like source that throws while `send()` releases it is + * logged (see `readStreamLike()`, `releaseStreamLike()`), as for a Node.js + * `Readable`: by the response, never by a listener left on the source. For + * the packages of binden only. + * + * @internal + */ +export interface IStreamLikeLogs { + /** Its guard refused (see `guard()`), or a `pause()` or a `destroy()` that throws */ + readonly released: IStreamLikeLog; + /** A removal of a listener that throws (its `removeListener()` or `off()`) */ + readonly removed: IStreamLikeLog; +} + +/** The logs of a release (see `IStreamLikeLogs`) by the report of a late error */ +function logsOf(report: ISourceReport): IStreamLikeLogs { + return { released: report, removed: report }; +} + +/** A method of an object (`null` when it is not a function) */ +function methodOf(value: object, name: string): IMethod | null { + const method: unknown = Reflect.get(value, name); + return typeof method === "function" ? (method as IMethod) : null; +} + +/** + * The methods of a stream-like source (see `IStreamLike`), read once: `null` + * for an object that is not one. Nothing of it is called. + */ +export function streamLikeOf(value: object): IStreamLikeMethods | null { + const on = methodOf(value, "on"); + const pause = methodOf(value, "pause"); + const resume = methodOf(value, "resume"); + const remove = methodOf(value, "removeListener") ?? methodOf(value, "off"); + if (on === null || pause === null || resume === null || remove === null) { + return null; + } + return { on, pause, resume, remove, destroy: methodOf(value, "destroy") }; +} + +/** + * Whether a stream-like source has emitted its `end`: `readableEnded`, or + * the state of a stream that has no such property (e.g. of `readable-stream` + * 2 and 3) + */ +function hasEnded(source: object): boolean { + const state = Reflect.get(source, "_readableState") as + { readonly endEmitted?: unknown } | null | undefined; + return ( + Reflect.get(source, "readableEnded") === true || state?.endEmitted === true + ); +} + +/** + * Whether a stream-like source has been destroyed: `destroyed`, or + * `_destroyed` (of `through2` 2, whose `destroy()` sets only that) + */ +function isDestroyed(source: object): boolean { + return ( + Reflect.get(source, "destroyed") === true || + Reflect.get(source, "_destroyed") === true + ); +} + +/** + * Whether a source has emitted its `close`: `closeEmitted` of its state + * (e.g. of a Node.js `Readable`, or of `readable-stream` 4, but not 2 nor 3, + * whose state does not tell), for its guard (see `guard()`) + * + * @internal + */ +export function hasClosed(source: object): boolean { + try { + const state = Reflect.get(source, "_readableState") as + { readonly closeEmitted?: unknown } | null | undefined; + return state?.closeEmitted === true; + } catch { + // Its state cannot be read (e.g. a getter of its own that throws): its + // guard waits for its `close` + return false; + } +} + +/** + * The error of a stream-like source that has failed before its end: + * `errored`, or the one of its state (e.g. of `readable-stream` 4, also + * once it has errored without being destroyed, with `autoDestroy: false`), + * `null` for none (nor for a falsy value, which Node.js drops as an error) + */ +function erroredOf(source: object): unknown { + const state = Reflect.get(source, "_readableState") as + { readonly errored?: unknown } | null | undefined; + const errored: unknown = Reflect.get(source, "errored") ?? state?.errored; + const failed = Boolean(errored); + return failed ? errored : null; +} + +/** The listener of `error` of the guard of each source (see `guard()`) */ +const guards = new WeakMap void>(); + +/** + * How many sends listen to the `error` of each source: an error that one of + * them hears fails it, and is not reported by the guard of the source too + * (see `guard()`) + */ +const hearing = new WeakMap(); + +/** + * Counts a send that listens to the `error` of a source (`1`), or no longer + * (`-1`): while one does, its guard (see `guard()`) and the detached + * listeners of other sends (see `forwarder()`) do not report an error of it, + * which fails that send. For the packages of binden only. + * + * @internal + */ +export function hear(source: object, change: 1 | -1): void { + const count = (hearing.get(source) ?? 0) + change; + if (count > 0) { + hearing.set(source, count); + } else { + hearing.delete(source); + } +} + +/** + * The errors that sends have failed with, and destroyed their sources with + * (see `decided()`): no guard reports them again + */ +const decisions = new WeakSet(); + +/** + * Records an error of binden that has decided a send (e.g. a body longer + * than its `Content-Length`), which the send destroys its source with once + * it has settled: no guard (see `guard()`) reports it when the source emits + * it (nor when a stream-like source read through it does). Only an object + * can be recorded: any other value (e.g. a string that a `resume()` of the + * source has thrown) is not, and is reported once the source emits it. For + * the packages of binden only. + * + * @internal + */ +export function decided(error: unknown): void { + if (Object(error) === error) { + decisions.add(error as object); + } +} + +/** + * An error of binden for a stream-like source (see `readStreamLike()`), once + * it is created: decided for every source (see `decided()`), as the other + * errors of binden that decide a send (e.g. a body longer than its + * `Content-Length`), so that no guard reports it, whatever stream emits it + * (e.g. another one that the application destroys with the error that the + * send has rejected with), and returned + */ +function own(error: T): T { + decided(error); + return error; +} + +/** + * The error of a chunk of a stream-like source that a response cannot send: + * neither a string nor bytes (e.g. `null`, which would end the readable), an + * error of binden (see `own()`), its stack formatted before anything has it + * (see `formatStack()`) + */ +function invalidChunk(chunk: unknown): TypeError { + const received = chunk === null ? "null" : typeof chunk; + const message = `The chunk of a stream-like source must be a string or bytes (received ${received})`; + return own( + Object.assign(formatStack(new TypeError(message)), { + code: "ERR_INVALID_ARG_TYPE", + }), + ); +} + +/** + * The error of a close before the end, an error of binden (see `own()`), its + * stack formatted (see `formatStack()`) + */ +function premature(): Error { + return own( + Object.assign(formatStack(new Error("Premature close")), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }), + ); +} + +/** + * The values of stream-like sources that each readable of `readStreamLike()` + * has been destroyed for, by the carriers that it has been destroyed with in + * their place (see `carry()`): only the errors of that readable are taken for + * them (see `uncarried()`), never a carrier that another stream emits (e.g. + * one that the application has kept from the `destroy()` of a source) + */ +const carriers = new WeakMap>(); + +/** The carriers of a readable that has none (see `carriers`) */ +const uncarrying = new WeakMap(); + +/** + * The carrier of a value of a stream-like source (see `carrierOf()`: an + * error of binden, decided as by `own()`, whose `stack` is a value that no + * `prepareStackTrace` of the application runs for), which a + * readable of `readStreamLike()` is destroyed with in its place: recorded for + * that readable only, whose send takes it for that value (see `uncarried()`) + */ +function carry(readable: object, value: unknown): Error { + const carrier = carrierOf(value); + const values = carriers.get(readable) ?? new WeakMap(); + carriers.set(readable, values.set(carrier, value)); + return carrier; +} + +/** + * What a readable of `readStreamLike()` is destroyed with for a value of its + * source (an error that it emits, or what its code throws), as the send of a + * Node.js `Readable` destroys its source (see `readsSafely()`): the value + * itself only when Node.js reads its `stack` without running code of the + * application, otherwise its carrier, for that readable (see `carry()`) + */ +function safely(readable: object, value: unknown): unknown { + return readsSafely(value) ? value : carry(readable, value); +} + +/** + * The value that an error of a readable of `readStreamLike()` stands for: the + * value of its source that a carrier of that readable carries (see + * `carry()`), which the send rejects with (and decides, see `decidedFor()`), + * as the send of a Node.js `Readable` does, or else the error itself (also a + * carrier of another readable, which another stream emits: e.g. one that the + * application has kept from the `destroy()` of a source, which is the value + * of that stream). For the packages of binden only. + * + * @internal + */ +export function uncarried(error: unknown, readable: object): unknown { + const values = carriers.get(readable) ?? uncarrying; + const carrier = error as object; + return values.has(carrier) ? values.get(carrier) : error; +} + +/** + * The sources that each late error has been reported for (see + * `reportOnce()`), whatever has reported it: the guard of the source (see + * `guard()`), its send (see `reportLate()`), or the listener of `error` of a + * send once it is detached (see `forwarder()`). Also the sources whose sends + * an error that they have emitted has decided (see `decidedFor()`) + */ +const reports = new WeakMap>(); + +/** + * The stream-like source that each readable of `readStreamLike()` reads: a + * late error reported for the readable (e.g. by its send, see `reportLate()`) + * is recorded for the source (see `reportOnce()`), so that its guard does not + * report it again + */ +const links = new WeakMap(); + +/** The readables of `readStreamLike()` whose sends have resolved (see `resolved()`) */ +const resolutions = new WeakSet(); + +/** + * Tells a readable of a stream-like source (see `readStreamLike()`) that its + * send has resolved, or settles without a failure (e.g. its body is complete + * for the client, or another writer has ended the response): released then, + * its source is destroyed without an error, as a Node.js `Readable` is, never + * with the reason of a cancellation (see `readStreamLike()`), which the + * listeners of `error` of the application, and its `errored`, would take for + * a failure. A readable destroyed with an error destroys its source with that + * error all the same. For the packages of binden only. + * + * @internal + */ +export function resolved(readable: object): void { + resolutions.add(readable); +} + +/** + * Records an error (an object) for a source, or for the stream-like source of + * a readable of `readStreamLike()` (see `links`): it is not reported for it + * (see `reportOnce()`) + */ +function record(source: object, failure: object): void { + const reported = links.get(source) ?? source; + const sources = reports.get(failure) ?? new WeakSet(); + reports.set(failure, sources.add(reported)); +} + +/** + * Records an error that a source has emitted, and that has decided its send + * (e.g. heard while the source is read): it is not reported for that source + * (nor for a readable of a stream-like source that reads it, see `links`), + * as if it had been already (see `reportOnce()`), but it still is for any + * other source (e.g. one error of the application that it destroys each of + * its streams with), unlike an error of binden (see `decided()`). Only an + * object can be recorded, as by `decided()`. For the packages of binden + * only. + * + * @internal + */ +export function decidedFor(source: object, error: unknown): void { + if (Object(error) === error) { + record(source, error as object); + } +} + +/** + * The errors that the sends of each source decide meanwhile (see + * `decide()`), each with how many of its decisions are pending: an inner + * decision with the same error (e.g. of a send of the source that its code + * starts meanwhile) that ends first leaves the outer one pending + */ +const pending = new WeakMap>(); + +/** + * Runs `call` while a send of a source decides `error` (e.g. the error that + * a destroyed source rejects it with), and returns what it returns: until + * `call` returns or throws, no guard (see `guard()`) nor any other report of + * a late error (see `reportOnce()`) reports that error for the source (nor + * for a readable of a stream-like source that reads it, see `links`), nor + * logs it as a refusal (see `refuse()`), whatever code of the source runs + * (e.g. a listener of its `newListener` that emits it, or throws it, while + * its guard is installed). Decisions nest: each is counted, so that an inner + * one with the same error (e.g. of a send of the same source that its code + * starts meanwhile) that ends first leaves the outer one pending. Only an + * object is known as decided (see `isKnown()`): any other value (e.g. a + * string) is reported, and logged. For the packages of binden only. + * + * @internal + */ +export function decide(source: object, error: unknown, call: () => T): T { + const decider = links.get(source) ?? source; + const counts = pending.get(decider) ?? new Map(); + pending.set(decider, counts.set(error, (counts.get(error) ?? 0) + 1)); + try { + return call(); + } finally { + const count = (counts.get(error) as number) - 1; + if (count > 0) { + counts.set(error, count); + } else { + counts.delete(error); + } + if (counts.size === 0) { + pending.delete(decider); + } + } +} + +/** + * Whether an error of a source (or of a readable of a stream-like source, see + * `links`) is known, and therefore not reported (see `reportOnce()`), nor + * logged as a refusal (see `refuse()`): it has decided a send (see + * `decided()`, or `decidedFor()` for that source), a send of the source + * decides it meanwhile (see `decide()`), or it has been reported for that + * source already. Only an object can be known: any other value (e.g. a + * string) is not. + */ +function isKnown(source: object, error: unknown): boolean { + if (Object(error) !== error) { + return false; + } + const failure = error as object; + const reported = links.get(source) ?? source; + return ( + decisions.has(failure) || + pending.get(reported)?.has(failure) === true || + reports.get(failure)?.has(reported) === true + ); +} + +/** + * A log that never throws (see `IStreamLikeLogs`): a logger of the + * application that throws (e.g. its own `debug()`) leaves no release of a + * stream-like source half done (e.g. the readable of `readStreamLike()` + * without the error that decides its send, or listeners left on the source), + * and throws neither into the events of the source nor out of a turn of its + * own. Nothing more can be told then. + */ +function quietly( + log: (...args: T) => void, +): (...args: T) => void { + return (...args: T): void => { + try { + log(...args); + } catch { + // The logger has failed: nothing more can be told + } + }; +} + +/** + * Reports a late error of a source (see `ISourceReport`) once, on a later + * turn (every report of a late error, whatever makes it: the guard of the + * source, see `guard()`, the listener of a send once it is detached, see + * `forwarder()`, or its send, see `reportLate()`), never an error that is + * known for it (see `isKnown()`): one that has decided a send, one that a + * send of it decides meanwhile, or one that has been reported for that source + * already (e.g. by its send, before the source emits it to its guard), also + * for a readable of a stream-like source (see `links`). Known when it is + * brought, it is dropped at once; else it is checked again on that turn, + * once the event that has brought it has reached every listener of the + * source, and so has what they schedule within the turn (e.g. the `error` of + * a readable of `readStreamLike()` that it destroys, which reaches its send + * on the next tick), whichever listens first, or emits it again meanwhile: a + * send of the source that it has decided since has recorded it then, and it + * is dropped. Only an object can be recorded, as by `decided()`: any other + * value (e.g. a string) is reported each time. Until then, it holds the + * source, the error and the report (see `ISourceReport`), nothing else; a + * report that throws (e.g. a logger of the application whose own `debug()` + * throws) is ignored (see `quietly()`), never thrown out of its turn. + */ +function reportOnce( + source: object, + error: unknown, + report: ISourceReport, +): void { + if (isKnown(source, error)) { + return; + } + setImmediate(() => { + if (isKnown(source, error)) { + return; + } else if (Object(error) === error) { + record(source, error as object); + } + quietly(report)(error); + }); +} + +/** + * Reports a late error of a source (see `ISourceReport`) that its send no + * longer fails with (e.g. once the response has been ended, see + * `BindenResponse.#pipe()`): on a later turn, once, whatever reports it (also + * the guard of the source, which does not while a send hears it, see + * `hear()`), and never once it has decided a send, also meanwhile (see + * `reportOnce()`). For the packages of binden only. + * + * @internal + */ +export function reportLate( + source: object, + error: unknown, + report: ISourceReport, +): void { + reportOnce(source, error, report); +} + +/** + * How a guard (see `guard()`) adds its listeners to a source, and removes + * them: by the methods of the source (see `IStreamLikeMethods`), or by + * `emitterMethods` + * + * @internal + */ +export type IGuardMethods = Pick; + +/** + * The methods of Node.js that add and remove the listeners of an emitter + * (`EventEmitter.prototype.on()` and `removeListener()`), for a guard (see + * `guard()`) of a source whose own methods are not read (e.g. a Node.js + * `Readable`, whose own `on` may be a getter) + * + * @internal + */ +export const emitterMethods: IGuardMethods = Object.freeze({ + on: Reflect.get(EventEmitter.prototype, "on") as IMethod, + remove: Reflect.get(EventEmitter.prototype, "removeListener") as IMethod, +}); + +/** + * The sources that have refused the listener of `error` of their guard (see + * `guard()`): they are not asked again, so that the refusal is reported once + */ +const refusals = new WeakSet(); + +/** + * Gives `refused` what the code of a source has thrown while its guard is + * installed (see `guard()`), unless it is an object that is known for the + * source (see `isKnown()`): an error that has decided a send, or that a send + * of the source decides meanwhile, which is not reported again. Any other + * value (e.g. a string) is given each time. A function of the module, so + * that no listener of the guard holds `refused` (it may hold the response) + */ +function refuse( + source: object, + refused: (error: unknown) => void, + error: unknown, +): void { + if (!isKnown(source, error)) { + refused(error); + } +} + +/** + * Removes the listener of `error` of a guard that is not installed (see + * `guard()`): what the code of the source throws is given to `refused` + */ +function unguard( + source: object, + remove: IMethod, + failed: (error: unknown) => void, + refused: (error: unknown) => void, +): void { + try { + Reflect.apply(remove, source, ["error", failed]); + } catch (error: unknown) { + refuse(source, refused, error); + } +} + +/** + * Removes a listener of the guard of a source that has closed (see + * `guard()`): a removal that throws is logged as such by the report, which + * holds only the logger (see `ISourceReport`, `removalFailure`), never as a + * late error, and nothing is thrown out of the turn of the guard (also when + * the logger throws, see `quietly()`) + */ +function leave( + source: object, + remove: IMethod, + event: string, + listener: (...args: never[]) => void, + report: ISourceReport, +): void { + try { + Reflect.apply(remove, source, [event, listener]); + } catch (error: unknown) { + quietly(report)(error, removalFailure); + } +} + +/** + * Keeps a late `error` of a source that is not sent, or no longer (released, + * cancelled, or destroyed by the application), from crashing the process + * without a listener of the application: its `destroy()` (or its `pause()`) may + * fail asynchronously, at any time. Installed before the source is paused or + * destroyed, and before a send removes its own listener of `error`. A guard of + * its own reports it (see `ISourceReport`), but not an error that a send hears + * (which fails the send), nor one that a send has failed with and destroyed it + * with (see `decided()`, `decidedFor()`), nor one that a send of the source + * decides meanwhile (see `decide()`), nor one that has been reported for the + * source already (see `reportOnce()`), and holds nothing else (never the + * response, nor the send), so that it may stay on a source that never emits + * anything: it is never removed by a timer, only on the turn after the `close` + * of a source whose state tells that it has closed (see `hasClosed()`, e.g. a + * Node.js `Readable`, or of `readable-stream` 4), or after it is installed for + * such a source that has emitted its `close` already (`closed`). A source whose + * state cannot tell it (e.g. of `readable-stream` 2 or 3, of `through2`, or an + * emitter of its own) keeps its guard as long as it lives: such a source may + * emit an error after its `close` (e.g. of a write once binden has destroyed + * it, or of a second destroy), each reported once. When it leaves, each of + * its listeners is removed on its own: a removal that throws is logged as such + * by the report (see `leave()`), never as a late error. One guard per source, + * however many sends release it. For the packages of binden only. + * + * Returns whether its listener of `error` is in place, and only then is the + * source guarded: a source that refuses it (its `on()` throws, e.g. by a + * listener of its `newListener`) is not, and is not asked again, so that + * the caller keeps a listener of its own (see `forwarder()`), or does not + * release it. What the code of the source throws (also for the listener of + * `close`, without which the guard stays) is given to `refused` (`report` by + * default), never thrown, unless it is an object known for the source (see + * `isKnown()`: e.g. an error that has decided a send, or that a send of the + * source decides meanwhile, see `decide()`), which is not reported again. + * + * @internal + */ +export function guard( + source: object, + { on, remove }: IGuardMethods, + report: ISourceReport, + closed = false, + refused: (error: unknown) => void = report, +): boolean { + if (guards.has(source)) { + return true; + } else if (refusals.has(source)) { + return false; + } + const failed = (error: unknown): void => { + if (!hearing.has(source)) { + // Once, whatever reports it (see `reportOnce()`) + reportOnce(source, error, report); + } + }; + const close = (): void => { + setImmediate(() => { + // Once (e.g. not for a second `close`), and only once its state tells + // that it has closed: a source that cannot tell it (e.g. of + // `readable-stream` 2 or 3, or an emitter of its own) may still emit + // an error after its `close` (e.g. of a write once it is destroyed, or + // of a second destroy), and keeps its guard + if (guards.get(source) === failed && hasClosed(source)) { + guards.delete(source); + // Each on its own: a removal that throws (e.g. by a listener of its + // `removeListener`) leaves the other one to be removed + leave(source, remove, "error", failed, report); + leave(source, remove, "close", close, report); + } + }); + }; + // `refused` is never held by its listeners (it may hold the response) + try { + Reflect.apply(on, source, ["error", failed]); + } catch (error: unknown) { + refusals.add(source); + refuse(source, refused, error); + // A throwing `on()` of its own may have added it + unguard(source, remove, failed, refused); + return false; + } + if (guards.has(source)) { + // Guarded meanwhile (e.g. by a listener of its `newListener` that + // releases it): one guard per source + unguard(source, remove, failed, refused); + return true; + } + // Only once its listener of `error` is in place + guards.set(source, failed); + try { + Reflect.apply(on, source, ["close", close]); + } catch (error: unknown) { + // It stays, holding nothing but the report + refuse(source, refused, error); + return true; + } + if (closed) { + close(); + } + return true; +} + +/** A listener of `error` (see `IForwarder`) */ +export type IErrorListener = (error: unknown) => void; + +/** + * The listener of `error` that a send adds to its source, which forwards + * each error to a target: the send while it listens (`target`), then, once + * it has settled (see `detach()`), the report of a late error, so that a + * listener kept on a source that refuses its guard (see `guard()`), or that + * a removal that throws leaves, holds nothing of the send (a function of + * its own scope). For the packages of binden only. + * + * @internal + */ +export interface IForwarder { + /** The listener of `error` that the send adds to the source */ + readonly listener: IErrorListener; + /** + * Forwards every later error to the report of a late error (see + * `ISourceReport`) as a guard does (see `guard()`), but not one that the + * guard of the source reports itself (it listens, and no send hears the + * source, see `hear()`) + */ + detach(source: object, report: ISourceReport): void; +} + +/** + * The listener of `error` of a send (see `IForwarder`), forwarded to + * `target` until it is detached. For the packages of binden only. + * + * @internal + */ +export function forwarder(target: (error: unknown) => void): IForwarder { + const forward = { target }; + return { + listener: (error: unknown): void => { + forward.target(error); + }, + detach: (source: object, report: ISourceReport): void => { + forward.target = (error: unknown): void => { + // Unless its guard reports it (see `guard()`): not while a send + // hears the source (e.g. the send of this listener, see `hear()`) + if (!guards.has(source) || hearing.has(source)) { + // Once, whatever reports it (see `reportOnce()`) + reportOnce(source, error, report); + } + }; + }, + }; +} + +/** + * Releases a stream-like source that is no longer read, by its `destroy()` + * (with `args`) or its `pause()`: what it throws (e.g. a `pause()` once + * more, after the one that has failed its send) is logged (see + * `IStreamLikeLogs`), never thrown into the events of the source, nor in + * place of the error that decides its send + */ +function releaseBy( + source: object, + method: IMethod, + released: IStreamLikeLog, + ...args: unknown[] +): void { + try { + Reflect.apply(method, source, args); + } catch (error: unknown) { + released(error); + } +} + +/** + * Does nothing: the default of what tells that a stream-like source is left + * untouched, since no listener of its `error` is in place (see + * `releaseStreamLike()`) + */ +function unnoticed(): void { + // Nobody to tell +} + +/** + * Releases a stream-like source that is not sent (e.g. to `HEAD`): it is + * destroyed (without an error), or paused, and never read. A late error of + * its destroy (or of its pause) is reported (see `guard()`). A source that + * refuses its guard is left untouched: no listener of its `error` is in + * place, without which an error of its destroy or of its pause (e.g. emitted + * asynchronously) would crash the process, so that none of its code runs + * (neither `destroy()` nor `pause()`), and `unreleased()` tells it: it is + * the application's to release. Code of the source that throws meanwhile is + * logged by `logs` (see `IStreamLikeLogs`). + */ +export function releaseStreamLike( + source: object, + methods: IStreamLikeMethods, + report: ISourceReport, + unreleased: () => void = unnoticed, + logs: IStreamLikeLogs = logsOf(report), +): void { + // Its guard listens before it is paused or destroyed + if (!guard(source, methods, report, false, logs.released)) { + // Untouched + unreleased(); + return; + } + // Destroyed, or paused without `destroy()`: what it throws is logged, and + // the send goes on (e.g. `HEAD` gets its reply) + releaseBy(source, methods.destroy ?? methods.pause, logs.released); +} + +/** A listener of an event of a stream-like source that `readStreamLike()` adds */ +type IAdded = (...args: never[]) => void; + +/** + * A listener that the readable of a stream-like source adds to it (see + * `readStreamLike()`), and what forgets its handler once it is removed (see + * `relay()`) + */ +interface IRelay { + /** The listener that the source has */ + readonly listener: IAdded; + /** Forgets its handler: the listener does nothing from then on */ + drop(): void; +} + +/** + * A listener of a stream-like source that calls `handler` until it is + * dropped (see `IRelay`), once the readable of `readStreamLike()` removes + * it: one that a removal that throws (e.g. a `removeListener()` of the + * source) leaves on the source then holds nothing of the readable, nor of its + * send (a function of its own scope, as the hooks of the readable, see + * `hooked()`). A Node.js `Readable` keeps such a listener of its own, which + * is the application's. + */ +function relay(handler: IAdded): IRelay { + const holder: { current: IAdded | null } = { current: handler }; + return { + listener: (...args: never[]): void => { + holder.current?.(...args); + }, + drop: (): void => { + holder.current = null; + }, + }; +} + +/** + * Forgets nothing: the listener of `error` of a send holds nothing of it once + * it is detached (see `forwarder()`), and reports the later errors of a + * source that a removal that throws leaves it on + */ +function forwarded(): void { + // Nothing to forget +} + +/** The hooks of a readable of `readStreamLike()` (see `hooked()`) */ +interface IReadableHooks { + /** Reads more of the source */ + read(): void; + /** Releases the source once the readable is destroyed */ + destroy(error: Error | null, callback: (error?: Error | null) => void): void; +} + +/** + * A Node.js `Readable` that runs its hooks (`hooks.current`) until it is + * destroyed, and forgets them then: once destroyed (Node.js neither reads it + * nor destroys it again), it holds nothing of them, nor of what they hold + * (e.g. the response that sends it, which the reason of its cancellation and + * its logs hold), so that what keeps it (e.g. a pending report of a late + * error of its source, see `reportOnce()`) keeps nothing of the response + */ +function hooked(hooks: { current: IReadableHooks | null }): Readable { + return new Readable({ + read(): void { + (hooks.current as IReadableHooks).read(); + }, + destroy(error, callback): void { + const current = hooks.current as IReadableHooks; + hooks.current = null; + current.destroy(error, callback); + }, + }); +} + +/** + * A stream-like source (see `IStreamLike`) read as a Node.js `Readable`, which + * `send()` sends as such. Its `data` is pushed with backpressure: the source is + * paused once the readable is full (e.g. while the response is), and resumed + * once it is read. A `data` that is neither a string nor bytes (e.g. `null`, + * never taken as its end) destroys it with a `TypeError` + * (`ERR_INVALID_ARG_TYPE`), and one that the readable cannot take (its `push()` + * throws, e.g. for a view of an `ArrayBuffer` that has been detached) with what + * it throws, never thrown into the `data` of the source. Its `end` ends the + * readable, and its `error`, or a `close` before its end, destroys it with that + * error (or `ERR_STREAM_PREMATURE_CLOSE`); a source that has failed without + * being destroyed (e.g. without `autoDestroy`) is then released under its + * guard, as for a cancellation, but without its error. An `end` or a `close` of + * a source that has been destroyed (e.g. `readable-stream` 2 emits both before + * the `error` of its `destroy()`) is not its end: the error that follows within + * a turn destroys the readable, or else `ERR_STREAM_PREMATURE_CLOSE`. Destroyed + * before (a cancellation: the deadline, the client gone away, another reply), + * the readable destroys the source with the reason of the cancellation + * (`reason()`, its stack formatted), recorded before as decided (see + * `decided()`), so that its guard does not report it once the source emits it, + * or pauses a source that cannot be destroyed; once its send has resolved (see + * `resolved()`), the source is destroyed without an error, as a Node.js + * `Readable` is. A destroy (or a pause) that throws is logged (see + * `IStreamLikeLogs`), and the readable is destroyed with the error that decides + * the send all the same. That error (an `error` of the source, or the one that + * the readable is destroyed with) is pending before the guard of the source is + * installed (see `decide()`): code of the source that emits it, or throws it + * again, meanwhile (e.g. a listener of its `newListener`) is neither reported + * nor logged when it is an object. + * + * Every listener that it adds is removed through the source (by its + * `removeListener()` or `off()`): at once for a cancellation, whatever the + * source emits then, and once the source has ended, failed or closed, but the + * one of `error` on the next turn (which an error emitted after the end or the + * close would otherwise crash the process without a listener of the + * application, and is reported as a late error). A removal that throws is + * logged by `logs` (see `IStreamLikeLogs`), and the listener that it leaves + * holds nothing of the send (see `relay()`, `forwarder()`). A source that has + * ended already is an empty body, one destroyed before its end closes the + * readable at once (with its error, its `errored`, without being destroyed + * again), and one that has failed without being destroyed (its `errored`, e.g. + * of `readable-stream` 4 with `autoDestroy: false`) destroys it at once with + * that error, and is released with it: none of them is listened to. Such an + * error is decided by the send (see `BindenResponse.#unreadable()`), which + * rejects with it, or reports it once (also for the source, see `reportOnce()`) + * when the response has ended meanwhile (e.g. by code of the source): no guard + * reports it meanwhile (e.g. emitted at once by the destroy of the source, or + * thrown again by its code while the guard is installed). The guard of a source + * (see `guard()`) listens before the listeners of the send are removed, and + * reports a later error of one that has ended (whose destroy, e.g. by + * `autoDestroy`, may fail after the end), failed or closed, that it cancels + * (also without `destroy()`), or that the application has destroyed (before the + * send, or by an `end` or a `close` once destroyed, whose destroy may fail + * after the turn that decides the send). + * + * Code of the source that throws while its listeners are added (its own `on()`, + * e.g. by a listener of its `newListener`), or in its `pause()`, gives a + * readable destroyed with what it has thrown (a falsy value, which Node.js + * drops as an error, as a premature close, with which the source is destroyed, + * decided), and releases the source as for a cancellation, with it. The + * readable is destroyed once, with a value of the source (its error, or what + * its code has thrown) only when Node.js reads its `stack` without running code + * of the application, otherwise with its carrier, an `Error` whose `cause` it + * is (see `safely()`, e.g. for a `stack` getter, which may return a string once + * and throw then, or a `prepareStackTrace` of the application that throws), and + * so is the source then: the send takes the carrier for the value (see + * `uncarried()`), as the send of a Node.js `Readable` rejects with it, whatever + * it destroys its source with. So it is when code of the application redefines + * that `stack` once binden has checked it (e.g. a getter of its `name`, which + * V8 reads as it formats the stack, or the `destroy()` of the source): the + * readable is destroyed with the carrier then, and emits it, while the + * `destroy()` of the source is called once. A source that refuses the + * listener of `error` of its guard keeps the one of the send (see + * `forwarder()`), and is destroyed without the error that decides the send; + * one that has neither is left untouched (see `releaseStreamLike()`): + * `unreleased()` tells it. A refusal of its guard, a `pause()` that throws once + * it is no longer read, and a removal that throws are logged by `logs` (see + * `IStreamLikeLogs`), not as late errors. + * A log that throws (also `unreleased()`, e.g. a logger of the application + * whose own `debug()` throws) is ignored, and the release goes on (see + * `quietly()`). Once destroyed, the readable holds none of them, nor `reason()` + * (see `hooked()`). + */ +export function readStreamLike( + source: object, + methods: IStreamLikeMethods, + reason: () => Error, + report: ISourceReport, + unreleased: () => void = unnoticed, + logs: IStreamLikeLogs = logsOf(report), +): Readable { + // What tells of its release never throws (see `quietly()`), also from its + // destroy (see `destroy`), which then calls back all the same + const released = quietly(logs.released); + const removed = quietly(logs.removed); + const untouched = quietly(unreleased); + /** + * The listeners added to the source, by event, recorded before its `on()` + * runs (which may add one, then throw): each through a relay (see + * `relay()`), dropped once it is removed, but the one of `error` (see + * `forwarder()`) + */ + const listeners = new Map(); + /** Whether the source has ended, failed or closed (or is cancelled) */ + let settled = false; + /** Whether it has emitted an `end` or a `close` once destroyed */ + let destroyed = false; + /** Whether its guard listens (see `protect()`), once installed */ + let guarded: boolean | null = null; + /** + * Whether the listener of `error` of the send is in place: its `on()` has + * returned, or it has heard an error + */ + let listened = false; + /** The listener of `error` of the send (see `detach()`) */ + const errors = forwarder((error: unknown) => { + listened = true; + // One decision, until the source that it fails is released: code of the + // source that emits that error again meanwhile (e.g. its own destroy()) + // is not reported (see `decide()`), whatever reaches the send first + decide(source, error, () => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- only once it is added, below + fail(error); + // Only while it is read: once the send has settled, this listener is + // detached (see `detach()`) + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- only once it is added, below + dropFailed(); + }); + }); + /** + * Calls a method of the source. Every call into code of the source throws + * nothing out of the adapter once it is read: its `on()` (see `listen`) and + * what its getters throw before (`hasEnded()`, `isDestroyed()` and + * `erroredOf()`, before anything is added) reject the send, its `pause()` + * (see its `data`) and its `resume()` (see `read`) destroy the readable + * (see `destroyWith()`), the getters of its state that it reads afterwards + * are caught (see `destroyedOnce()`, `dropFailed()`, `hasClosed()`), and + * what its removals, its release and its guard throw is logged (see + * `release()`, `releaseBy()`, `guard()`) + */ + const call = (method: IMethod, ...args: unknown[]): void => { + Reflect.apply(method, source, args); + }; + const listen = (event: string, handler: IAdded): void => { + if (settled) { + // E.g. by an `error` that its `on()` has emitted at once + return; + } + const added = + event === "error" + ? { listener: handler, drop: forwarded } + : relay(handler); + listeners.set(event, added); + if (event === "error") { + // Until it is removed (see `release()`) + hear(source, 1); + } + call(methods.on, event, added.listener); + if (event === "error") { + listened = true; + } + }; + /** Removes the listeners of these events that the source still has */ + const release = (...names: readonly string[]): void => { + for (const event of names) { + const added = listeners.get(event); + if (typeof added !== "undefined") { + listeners.delete(event); + // Also when its removal throws, and leaves it on the source + added.drop(); + try { + call(methods.remove, event, added.listener); + } catch (error: unknown) { + // Never thrown into the events of the source, nor out of a turn of + // its own: once the send has settled, its listeners change nothing + removed(error); + } finally { + if (event === "error") { + // The send no longer hears it, also when the removal has thrown + // (before or after it has removed the listener): a listener left + // is detached (see `detach()`), and reports a later error itself + // only when its guard does not (see `forwarder()`) + hear(source, -1); + } + } + } + } + }; + /** + * Installs the guard of the source once (see `guard()`): whether it + * listens (`closed`: by its `close`) + */ + const protect = (closed = false): boolean => { + guarded ??= guard(source, methods, report, closed, released); + return guarded; + }; + /** + * The send no longer listens to the source: its listener of `error` no + * longer fails it (see `forwarder()`), before the guard of the source is + * installed, whose `on()` runs code of the source that may emit an error + * (e.g. a listener of its `newListener`), reported once as a late error, + * never by the settled send. That listener is kept when no guard listens + * (`closed`: by its `close`): whether it is. + */ + const detach = (closed = false): boolean => { + errors.detach(source, report); + const kept = !protect(closed) && listened; + if (kept) { + // Never removed (it holds nothing of the send): it reports a late + // error itself, heard as long as it listens (see `hear()`) + listeners.delete("error"); + } + return kept; + }; + /** + * Removes every listener but the one of `error`, which is removed on the + * next turn (see above), unless it is kept. Its guard listens before + * (`closed`: by its `close`, see `guard()`), unless it has already, once + * destroyed (see `stopped()`): its destroy may still fail (e.g. by + * `autoDestroy` once it has ended). + */ + const settle = (closed = false): void => { + settled = true; + detach(closed); + release("data", "end", "close"); + setImmediate(() => { + release("error"); + }); + }; + // Once destroyed, it holds nothing of the send (see `hooked()`) + const readable = hooked({ + current: { + // Reads more of the source: it flows again (never once it has settled: + // the readable has ended or is destroyed) + read: (): void => { + try { + call(methods.resume); + } catch (error: unknown) { + // Its own `resume()` (the first one, or once the readable has room + // again): as its `pause()`, never thrown out of `read()` of + // Node.js, which would destroy the readable with it as it is + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- only once it is read, below + destroyWith(error); + } + }, + // Destroyed before the source has settled: it is cancelled, and its + // own cleanup ends at once (the destroy of the source may emit nothing) + destroy: (error, callback): void => { + if (!settled) { + settled = true; + // The error that it is destroyed with decides the send (for a + // carrier, the value that it carries, see `uncarried()`): pending + // before its guard is installed, and while it is released, so that + // its code that emits it or throws it meanwhile (e.g. a listener of + // its `newListener`, or its destroy) is not reported (see `decide()`) + decide(source, uncarried(error, readable), () => { + // Its guard listens before its listeners are removed, and before + // it is paused or destroyed, once the listener of `error` of the + // send no longer fails it (see `detach()`) + const kept = detach(); + const safe = protect(); + release("data", "end", "close", "error"); + const { destroy, pause } = methods; + if (!safe && !kept) { + // No listener of its `error` is in place: untouched (see + // `releaseStreamLike()`) + untouched(); + } else if (destroy === null) { + releaseBy(source, pause, released); + } else if (destroyed) { + // By the application: not again + } else if (kept) { + // Only the listener of the send hears it: without the error that + // decides the send, which the send has failed with already + releaseBy(source, destroy, released); + } else if (error !== null) { + // The error that decides the send, as the readable has it (never + // a value whose `stack` Node.js cannot read safely, but its + // carrier, see `destroyWith()`): e.g. what a `pause()` of the + // source has thrown, of a chunk that the readable cannot take, + // or the error of a source that has failed before (see + // `unreadable()`). Pending while it is released (see above), a + // value of the application is decided for the source only once + // the send rejects with it (see `decidedFor()`, by the send), + // and is otherwise a late error (e.g. displaced by a + // cancellation of the send, or once the response has ended), + // reported once. An error of binden (e.g. of a chunk that is not + // bytes, or a carrier) is decided for every source already (see + // `own()`) + releaseBy(source, destroy, released, error); + } else if (resolutions.has(readable)) { + // Its send has resolved (see `resolved()`): without an error, as + // a Node.js `Readable` is, never with the reason of a + // cancellation + releaseBy(source, destroy, released); + } else { + // The reason of its cancellation (also for a falsy value that its + // code has thrown, a premature close), an error of binden: no + // guard reports it once the source emits it (see `decided()`). + // Formatted before the source has it, which may keep it + const failure = formatStack(reason()); + decided(failure); + releaseBy(source, destroy, released, failure); + } + }); + } + // With the error that decides the send, never one of its release. + // Node.js reads its `stack` again then (and would leave the readable + // destroyed without its `error` nor its `close` if that threw): code + // of the source that has run since it was read (e.g. its own + // `destroy()`, given that error) may have redefined it, and its + // carrier, for the value that it stands for, is called back then + callback( + error === null || readsSafely(error) + ? error + : carry(readable, uncarried(error, readable)), + ); + }, + }, + }); + // Its late errors are the ones of the source (see `reportOnce()`) + links.set(readable, source); + /** + * Destroys the readable with an error of the source, or with what its code + * has thrown, by the `destroy()` of Node.js: with that value itself only + * when Node.js reads its `stack` (once before the readable is destroyed, + * and again once `destroy` has called back) without running code of the + * application, otherwise with its carrier (see `safely()`), which the send + * takes for that value, so that the readable is destroyed in any case, + * emits its `error` and its `close`, and releases the source (see + * `destroy`) with that same value. A verdict that has gone stale (code of + * the application that has run since, e.g. a getter of its `name` while + * its stack is formatted, has redefined its `stack`) makes Node.js throw + * as it reads it, before the readable is destroyed, and before anything of + * the source has run (its `destroy()` is called once, by `destroy`): it is + * destroyed once more then, with its carrier + */ + const destroyWith = (error: unknown): void => { + try { + readable.destroy(safely(readable, error) as Error); + } catch { + // What reading its `stack` has thrown is of the value, which the + // carrier carries (as `readsSafely()` ignores it). Node.js destroys a + // readable once: a readable destroyed already would be left as it is + readable.destroy(carry(readable, error)); + } + }; + /** + * Fails the readable with an error of the source (`closed`: by its + * `close`), which decides the send before the guard of the source is + * installed (see `settle()`): code of the source that emits it or throws it + * meanwhile (e.g. a listener of its `newListener`) is not reported (see + * `decide()`) + */ + const fail = (error: unknown, closed = false): void => { + decide(source, error, () => { + settle(closed); + destroyWith(error); + }); + }; + /** + * Releases a source that has failed (its `error`) while it was read, + * without being destroyed (e.g. of `readable-stream` 4 with `autoDestroy: + * false`), so that what it holds is freed, as for a cancellation (see + * `destroy`), but without its error, which it has emitted already: under + * its guard, or else the listener of `error` of the send, which has heard + * it, and is kept (see `detach()`). That error is still pending (see + * `errors`): a destroy of its own that emits it again is not reported. + */ + const dropFailed = (): void => { + try { + if (destroyed || isDestroyed(source)) { + // By itself (e.g. by `autoDestroy`), or by the application + return; + } + } catch (error: unknown) { + // Its state cannot be read (e.g. a getter of its own that throws) + released(error); + return; + } + releaseBy(source, methods.destroy ?? methods.pause, released); + }; + /** + * A readable that nothing is read into, destroyed at once with `error` + * (see `BindenResponse.#unreadable()`), which the send rejects with, and + * decides (see `decided()`), or reports once, as a late error, when the + * response has ended (e.g. by code of the source that the send runs), + * recorded for the source (see `links`): its guard never reports it, as if + * a send heard it. The source is released with it, unless it has settled + * (e.g. destroyed already, see `destroy`). + */ + const unreadable = (error: unknown): Readable => { + // Meanwhile, its guard does not report it, whatever reports it (e.g. a + // refusal of the listener of its `close`, or its destroy) + decide(source, error, () => { + hear(readable, 1); + destroyWith(error); + }); + return readable; + }; + /** + * An `end` or a `close` of a source that has been destroyed, which is not + * its end: the error of its destroy, which may follow, fails the readable, + * or a premature close on the next turn (which changes nothing once the + * readable is destroyed). Its destroy may fail later: its guard listens + * before the listener of `error` of the send is removed (`closed`: by its + * `close`). + */ + const stopped = (closed: boolean): void => { + if (!destroyed) { + destroyed = true; + protect(closed); + release("data", "end", "close"); + setImmediate(() => { + fail(premature()); + }); + } + }; + /** + * Whether the source has been destroyed (see `isDestroyed()`) once it has + * ended or closed (`closed`): `null` when its code throws (e.g. a getter of + * its own `destroyed`), which destroys the readable (see `destroyWith()`), + * and releases the source, as for a cancellation, with what it has thrown, + * under its guard (`closed`: by its `close`), never thrown into its events. + * That value decides the send meanwhile (see `decide()`): the listener of + * `error` of the send no longer fails it before the guard is installed + * (see `forwarder()`), so that another error that code of the source emits + * then (e.g. a listener of its `newListener`) is a late one, reported + * once, never the one that the send rejects with. + */ + const destroyedOnce = (closed: boolean): boolean | null => { + try { + return isDestroyed(source); + } catch (error: unknown) { + decide(source, error, () => { + errors.detach(source, report); + protect(closed); + destroyWith(error); + }); + return null; + } + }; + if (hasEnded(source)) { + // Its destroy may still fail (e.g. by `autoDestroy` once it has ended) + settled = true; + protect(hasClosed(source)); + readable.push(null); + return readable; + } + if (isDestroyed(source)) { + // Nothing more of it can be read (see `BindenResponse.#pipe()`), but its + // destroy may still fail. Destroyed with an error (its `errored`), it + // rejects with that error, as a Node.js `Readable` does, and is not + // destroyed again + settled = true; + let failure: unknown = null; + try { + failure = erroredOf(source); + } finally { + // Read before its guard is installed, which the send decides + // meanwhile (see `decide()`): its code that emits it or throws it then + // (e.g. a listener of its `newListener`) is not reported. Its guard + // listens also when its state cannot be read (e.g. a getter of its own + // that throws), which the send rejects with + decide(source, failure, () => protect(hasClosed(source))); + } + if (failure !== null) { + return unreadable(failure); + } + readable.destroy(); + return readable; + } + const errored = erroredOf(source); + if (errored !== null) { + // Failed without being destroyed: nothing more of it can be read either, + // and it is released with its error, as for a cancellation (see + // `destroy`) + return unreadable(errored); + } + try { + listen("error", errors.listener); + listen("close", () => { + // Before its end (once it has ended, it is no longer listened to) + const gone = destroyedOnce(true); + if (gone === true) { + stopped(true); + } else if (gone === false) { + fail(premature(), true); + } + }); + listen("end", () => { + const gone = destroyedOnce(false); + if (gone === true) { + stopped(false); + } else if (gone === false) { + settle(); + readable.push(null); + } + }); + listen("data", (chunk: unknown) => { + if (typeof chunk !== "string" && !ArrayBuffer.isView(chunk)) { + // Never its end (`null`), nor dropped (`undefined`), as a `push()` + // would take them: refused as any other value that is not bytes + destroyWith(invalidChunk(chunk)); + return; + } + try { + if (!readable.push(chunk)) { + call(methods.pause); + } + } catch (error: unknown) { + // A chunk that the readable cannot take (e.g. a view of an + // `ArrayBuffer` that has been detached), or code of the source that + // throws: as for a cancellation, with it, never thrown into the + // `data` of the source + destroyWith(error); + } + }); + } catch (error: unknown) { + // Code of the source (e.g. its own `on()`, or a listener of its + // `newListener`): released as for a cancellation, with what it has + // thrown (see `destroy`), whatever it has added removed + return unreadable(error); + } + return readable; +} diff --git a/packages/binden/test/access-log.test.ts b/packages/binden/test/access-log.test.ts new file mode 100644 index 00000000..d60405f9 --- /dev/null +++ b/packages/binden/test/access-log.test.ts @@ -0,0 +1,494 @@ +import { + Binden, + BindenError, + type Context, + type IBindenOptions, + Router, + Stack, +} from "../index.js"; +import { + type ICaptured, + type IListening, + capture, + listen, + never, + pollUntil, + send, + sleep, +} from "./helpers.js"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; + +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 { + await pollUntil(() => records(captured).length >= count).catch(() => null); +} + +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 route of the layer that has started the reply", async () => { + await withApp( + { access_log: true }, + (app) => { + const api = new Stack() + // Runs, but does not reply + .route("/users/:id", (context: Context) => { + context.state.seen = true; + }) + .route("/items/:id", () => { + throw new BindenError(404); + }) + .use((context: Context) => { + if (context.url.pathname === "/api/fallback") { + return context.send("api"); + } + return context; + }) + .route( + "/guarded", + new Router({ guarded: true }).get((context: Context) => + context.send("guarded"), + ), + ); + app + // Runs for every request + .use((context: Context) => { + context.state.logged = true; + }) + .mount("/api", api) + .use("/root", (context: Context) => { + // Started here, but not done: the next layers do not take it + context.response.write("root"); + }) + .use((context: Context) => { + if (context.url.pathname.startsWith("/api/users/")) { + return context.status(404).send("fallback"); + } else if (context.url.pathname === "/root") { + context.response.end(); + context.done = true; + } + return context; + }); + }, + async ({ port }, captured) => { + const paths = [ + "/api/users/1", + "/api/items/1", + "/api/fallback", + "/root", + "/nothing", + ]; + for (const path of paths) { + await send(port, { path }); + } + await send(port, { path: "/api/guarded", method: "POST" }); + await waitFor(captured, 6); + deepStrictEqual( + records(captured).map(({ route, status }) => [route, status]), + [ + // The fallback of the root, not the route that has run before + ["*", 404], + // The error of a route + ["/api/items/:id", 404], + // A `use()` without a path in a mount + ["/api", 200], + ["/root", 200], + // The `404` and `405` of the framework + ["", 404], + ["", 405], + ], + ); + }, + ); + }); + + 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: 204, + 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("the bytes of a string whose length Node.js only estimates, as written", async () => { + await withApp( + { access_log: true }, + (app) => { + app.use((context: Context) => { + const { response } = context; + context.done = true; + // An odd and an invalid hex string, an invalid base64 (any case) + response.write("68692", "hex"); + response.write("zz", "HEX" as BufferEncoding); + response.write("aGk", "Base64" as BufferEncoding); + response.end("aGk", "base64url"); + }); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).body, "hihihi"); + await waitFor(captured, 1); + deepStrictEqual(records(captured)[0]?.body_bytes, 6); + }, + ); + }); + + test("only the writes that Node.js accepts are counted", async () => { + const accepted: boolean[] = []; + const refused: unknown[] = []; + const large = "x".repeat(64 * 1024); + await withApp( + { access_log: true }, + (app) => { + app.use((context: Context) => { + const { response } = context; + response.on("error", (error: Error) => { + // A write after the end is refused with an `error` event + refused.push((error as NodeJS.ErrnoException).code); + }); + // Under backpressure (`false`), but accepted + response.cork(); + accepted.push(response.write(large)); + response.uncork(); + response.end("OK"); + accepted.push( + response.write("EXTRA", (error?: Error | null) => { + refused.push((error as NodeJS.ErrnoException).code); + }), + ); + context.done = true; + }); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).body, `${large}OK`); + await waitFor(captured, 1); + deepStrictEqual(records(captured)[0]?.body_bytes, large.length + 2); + deepStrictEqual(accepted, [false, false]); + deepStrictEqual(refused, [ + "ERR_STREAM_WRITE_AFTER_END", + "ERR_STREAM_WRITE_AFTER_END", + ]); + }, + ); + await withApp( + { access_log: true }, + (app) => { + app.use((context: Context) => { + const { response } = context; + response.write("abc"); + response.destroy(); + // Refused (`ERR_STREAM_DESTROYED`) + response.write("def"); + response.end("ghi"); + context.done = true; + }); + }, + async ({ port }, captured) => { + await rejects(send(port)); + await waitFor(captured, 1); + deepStrictEqual(records(captured)[0]?.body_bytes, 3); + }, + ); + }); + + 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("the ignored writes of a work past its deadline are not counted", async () => { + await withApp( + { + access_log: true, + timeout: 20, + error_handler: async (context: Context): Promise => { + await sleep(60); + await context.status(503).text("deadline!"); + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(30); + // Past the deadline: the response no longer belongs to this work + context.response.write("x".repeat(1000)); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [503, "deadline!"]); + await waitFor(captured, 1); + deepStrictEqual(records(captured), [ + { + level: 30, + method: "GET", + route: "*", + status: 503, + body_bytes: 9, + 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, + }, + ]); + }, + ); + }); +}); diff --git a/packages/binden/test/allowed-hosts.test.ts b/packages/binden/test/allowed-hosts.test.ts new file mode 100644 index 00000000..3e3c0be1 --- /dev/null +++ b/packages/binden/test/allowed-hosts.test.ts @@ -0,0 +1,284 @@ +import { + BindenError, + BindenRequest, + type IAllowedHosts, + type IRequestOptions, + type IRequestOptionsInput, + validateRequestOptions, +} from "../index.js"; +import { after, suite, test } from "node:test"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { Socket } from "node:net"; +import { TLSSocket } from "node:tls"; + +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; + // As Node.js does: one field line of each + request.headersDistinct = Object.fromEntries( + Object.entries(headers).map(([name, value]) => [name, [value]]), + ); + 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/argument-refusals.test.ts b/packages/binden/test/argument-refusals.test.ts new file mode 100644 index 00000000..43d71113 --- /dev/null +++ b/packages/binden/test/argument-refusals.test.ts @@ -0,0 +1,213 @@ +import { Binden, type BindenResponse, type Context } from "../index.js"; +import { listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Calls a method of the response once for a request, with arguments that + * JavaScript may give in place of the declared ones: what it has settled + * with (the class and the message of its error), whether the headers were + * sent then, its status and its `Content-Type` and `Location`, and the reply + * that the client gets once the middleware has replied `200 replied` itself + */ +async function calling( + call: (response: BindenResponse) => Promise, +): Promise<{ outcome: unknown; state: unknown; reply: unknown }> { + const settled = Promise.withResolvers<{ outcome: unknown; state: unknown }>(); + const app = new Binden().use(async ({ response }: Context) => { + let outcome: unknown = "resolved"; + try { + await call(response); + } catch (error: unknown) { + outcome = `${(error as Error).constructor.name}: ${(error as Error).message}`; + } + settled.resolve({ + outcome, + state: [ + response.headersSent, + response.statusCode, + response.getHeader("Content-Type") ?? null, + response.getHeader("Location") ?? null, + ], + }); + if (!response.headersSent) { + await response.status(200).send("replied"); + } + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + return { ...(await within(settled.promise)), reply: [status, body] }; + } finally { + await listening.close(); + } +} + +/** A value of any type, given in place of the one that a method declares */ +function given(value: unknown): T { + return value as T; +} + +/** What a refusal leaves: nothing of the response changed, and its reply */ +const unchanged = { state: [false, 200, null, null], reply: [200, "replied"] }; + +suite( + "The methods of the response refuse the arguments that they cannot take with a TypeError of binden, before they change anything (R24-2)", + () => { + test("json() with a stringify that is not a function", async () => { + for (const [label, stringify] of [ + ["a number", 5], + ["null", null], + ["a string", "JSON.stringify"], + ["an object", {}], + ] as const) { + deepStrictEqual( + await calling((response) => + response.json({ a: 1 }, given<() => string>(stringify)), + ), + { + outcome: "TypeError: `stringify` is not a function", + ...unchanged, + }, + label, + ); + } + // Without one, or with a function + deepStrictEqual( + await calling((response) => response.json({ a: 1 }, () => '{"b":2}')), + { + outcome: "resolved", + state: [true, 200, "application/json", null], + reply: [200, '{"b":2}'], + }, + ); + }); + + test("redirect() with options that are not an object", async () => { + for (const [label, options] of [ + ["null", null], + ["a number", 5], + ["a string", "external"], + ["a boolean", true], + ] as const) { + deepStrictEqual( + await calling((response) => + response.redirect("/next", 303, given(options)), + ), + { outcome: "TypeError: `options` is not an object", ...unchanged }, + label, + ); + } + // An object, also an empty one, or none + for (const [label, call] of [ + [ + "{}", + (response: BindenResponse): Promise => + response.redirect("/next", 303, {}), + ], + [ + "allow_external", + (response: BindenResponse): Promise => + response.redirect("/next", 303, { allow_external: false }), + ], + [ + "none", + (response: BindenResponse): Promise => + response.redirect("/next", 303), + ], + ] as const) { + deepStrictEqual( + await calling(call), + { + outcome: "resolved", + state: [true, 303, null, "/next"], + reply: [303, ""], + }, + label, + ); + } + }); + + test("form() with anything but URLSearchParams (e.g. null, a string, an object that only looks like them, or a proxy of them)", async () => { + for (const [label, params] of [ + ["null", null], + ["a string", "a=1"], + ["an object", { toString: (): string => "a=1" }], + ["a lookalike", Object.create(URLSearchParams.prototype) as object], + ["a proxy", new Proxy(new URLSearchParams("a=1"), {})], + ] as const) { + deepStrictEqual( + await calling((response) => + response.form(given(params)), + ), + { + outcome: "TypeError: The parameters are not URLSearchParams", + ...unchanged, + }, + label, + ); + } + }); + + test("form() with URLSearchParams sends them as Node.js serializes them, also of a subclass", async () => { + class Params extends URLSearchParams { + public override toString(): string { + return `overridden ${this.size}`; + } + } + for (const params of [ + new URLSearchParams([ + ["a", "1"], + ["b", "x y"], + ]), + new Params([ + ["a", "1"], + ["b", "x y"], + ]), + ]) { + deepStrictEqual( + await calling((response) => response.form(params)), + { + outcome: "resolved", + state: [true, 200, "application/x-www-form-urlencoded", null], + reply: [200, "a=1&b=x+y"], + }, + params.constructor.name, + ); + } + }); + }, +); + +suite( + "A refusal of an argument, kept by the application without its stack read, keeps neither the request nor the response", + () => { + for (const [mode, how] of [ + ["form", "of form(null)"], + ["options", "of redirect() with null options"], + ["stringify", "of json() with a stringify that is not a function"], + ] as const) { + test(how, () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "created-error-retention.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + // 60 requests, whose errors are kept + deepStrictEqual( + { status, stdout, stderr }, + { status: 0, stdout: "collected 60 TypeError\n", stderr: "" }, + ); + }); + } + }, +); diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts new file mode 100644 index 00000000..f261d99d --- /dev/null +++ b/packages/binden/test/binden.test.ts @@ -0,0 +1,1307 @@ +/* eslint-disable init-declarations, class-methods-use-this, @typescript-eslint/only-throw-error */ +import { + Binden, + BindenError, + Context, + Cookie, + DefaultErrorCode, + type IBindenErrorOptions, + type IBindenOptions, + type IMiddlewareParams, + Middleware, + Router, + ct_html, + ct_json, + ct_text, + serializers, +} from "../index.js"; +import { Logger, type LoggerOptions } from "@binden/logger"; +import { Server, request as httpRequest } from "node:http"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; +import { pollUntil, sleep } from "./helpers.js"; +import type { AddressInfo } from "node:net"; +import { Agent } from "node:https"; +import { Readable } from "node:stream"; +import fetch from "node-fetch"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +class CustomMiddleware extends Middleware { + public readonly id: string; + public constructor(params?: IMiddlewareParams) { + super(params); + this.id = randomUUID(); + } + public run(context: Context): Context | Promise | Promise { + return context; + } +} +class SendMiddleware extends CustomMiddleware { + public run(context: Context): Promise { + return context.send(this.id); + } +} +class ErrorMiddleware extends CustomMiddleware { + readonly #error: BindenError; + public constructor( + params?: IMiddlewareParams, + status = 401, + error_params: IBindenErrorOptions = {}, + ) { + super(params); + this.#error = new BindenError(status, error_params); + } + public get error(): BindenError { + return this.#error; + } + public run(context: Context | undefined): Promise; + public run(): Promise { + return Promise.reject(this.#error); + } +} + +/** The port of a listening server */ +function portOf(server: { address(): unknown }): number { + return (server.address() as AddressInfo).port; +} + +let url = ""; + +interface ILoggedApp { + app: Binden; + url: string; + /** The log lines written so far */ + output(): string; + close(): Promise; +} + +/** An application (on another port) 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(); + await new Promise((resolve) => { + loggedServer.listen(0, "127.0.0.1", resolve); + }); + const loggedUrl = `http://127.0.0.1:${portOf(loggedServer)}`; + 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; + + beforeEach((_t, done) => { + app = new Binden(); + server = app.createServer().listen(0, "127.0.0.1", () => { + url = `http://127.0.0.1:${portOf(server)}`; + done(); + }); + }); + + test(".createSecureServer()", async () => { + const newApp = new Binden(); + const path = new URL(url); + path.protocol = "https:"; + + const key = await readFile("./test/cert/key.pem", { encoding: "utf-8" }); + const cert = await readFile("./test/cert/cert.pem", { encoding: "utf-8" }); + // The certificate of the tests (for 127.0.0.1) is its own authority: it is + // verified like any other + const agent = new Agent({ ca: cert }); + + const newServer = newApp.createSecureServer({ key, cert }); + + await new Promise((resolve) => { + newServer.listen(0, "127.0.0.1", resolve); + }); + path.port = `${portOf(newServer)}`; + + const m = new SendMiddleware(); + 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" }; + try { + const response = await fetch(path.toString(), { + agent, + headers, + signal: AbortSignal.timeout(10_000), + }); + deepStrictEqual(response.status, 200); + const data = await response.text(); + deepStrictEqual(data, m.id); + deepStrictEqual(protocol, "https:"); + } finally { + newServer.closeAllConnections(); + 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(10_000); + + 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(); + const m3 = new CustomMiddleware(); + const r1 = new Router(); + const r2 = new Router(); + + const reg = new RegExp("Something", "u"); + + deepStrictEqual(app.stack, []); + + throws(() => app.use(), new TypeError("Unsupported Middleware/Router")); + deepStrictEqual(app.stack, []); + + deepStrictEqual(app.use("/path", m1).stack, [[[m1], "/path"]]); + + throws(() => { + app.use("/", {} as Router); + }, new TypeError("Unsupported Middleware/Router")); + + deepStrictEqual(app.use("/path", m2).stack, [[[m1, m2], "/path"]]); + + deepStrictEqual(app.off(), []); + deepStrictEqual(app.stack, [[[m1, m2], "/path"]]); + + deepStrictEqual(app.use("/path", r2, m3).stack, [ + [[m1, m2, r2, m3], "/path"], + ]); + + deepStrictEqual(app.use("/path2", r1).stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + ]); + + deepStrictEqual(app.use(m3).stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m3], null], + ]); + + deepStrictEqual(app.use(m2).stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m3, m2], null], + ]); + + deepStrictEqual(app.use(reg, m3, r1, m1).stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m3, m2], null], + [[m3, r1, m1], reg], + ]); + + deepStrictEqual(app.off(m3), [m3]); + deepStrictEqual(app.stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m2], null], + [[m3, r1, m1], reg], + ]); + + deepStrictEqual(app.use(m3).stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m2], null], + [[m3, r1, m1], reg], + [[m3], null], + ]); + + deepStrictEqual(app.off(reg, r1), [r1]); + deepStrictEqual(app.stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m2], null], + [[m3, m1], reg], + [[m3], null], + ]); + + deepStrictEqual(app.off(reg, m1, m3), [m1, m3]); + deepStrictEqual(app.stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m2, m3], null], + ]); + + deepStrictEqual(app.use("/path3", m3).stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m2, m3], null], + [[m3], "/path3"], + ]); + + deepStrictEqual(app.off(m2, m3), [m2, m3]); + deepStrictEqual(app.stack, [ + [[m1, m2, r2, m3], "/path"], + [[r1], "/path2"], + [[m3], "/path3"], + ]); + + deepStrictEqual(app.off("/path", m1, m2, r2, m3), [m1, m2, r2, m3]); + deepStrictEqual(app.stack, [ + [[r1], "/path2"], + [[m3], "/path3"], + ]); + + deepStrictEqual(app.off("/path2", m3, r1), [r1]); + deepStrictEqual(app.stack, [[[m3], "/path3"]]); + + deepStrictEqual(app.off("/path3", m3, r1), [m3]); + 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); + + const newServer = newApp.createServer(); + + await new Promise((resolve) => { + newServer.listen(0, "127.0.0.1", resolve); + }); + path.port = `${portOf(newServer)}`; + + 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 }, + // A client error is logged at `debug` + { msg: "The request has failed", trace_id: id }, + ], + ); + } finally { + await new Promise((resolve) => { + newServer.close(resolve); + }); + } + }); + + 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(10_000), + }); + + deepStrictEqual(response.status, DefaultErrorCode); + ok( + logged.output().includes(`"failure":{"type":"Error","status":500,`), + logged.output(), + ); + } finally { + await logged.close(); + } + }); + + 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(10_000), + }); + + 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(10_000), + }); + + 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(10_000), + }); + + deepStrictEqual(response.status, DefaultErrorCode); + ok( + logged.output().includes(`"failure":{"type":"Error","status":500,`), + logged.output(), + ); + ok(!logged.output().includes("SECRET"), logged.output()); + } finally { + await logged.close(); + } + }); + + 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(10_000), + }); + + deepStrictEqual(response.status, DefaultErrorCode); + const output = logged.output(); + ok(!output.includes("SECRET"), output); + ok(output.includes(`"failure":{"type":"Error","status":500,`), 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(10_000), + }); + + deepStrictEqual(response.status, 401); + } finally { + await logged.close(); + } + }); + + 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(10_000), + }); + + 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(10_000), + }); + + deepStrictEqual(response.status, 418); + deepStrictEqual(await response.text(), "teapot"); + ok(logged.output().includes("has not replied"), logged.output()); + } finally { + await logged.close(); + } + }); + + 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(10_000), + }); + deepStrictEqual(response.status, 500, path); + deepStrictEqual(await response.text(), "", path); + } + }); + + 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(10_000) }); + + 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" })); + throw new BindenError(401, { expose: true }); + }); + + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + + 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(10_000), + }) + .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("does not warn of an `error_handler` reply that is not ended once its response has been closed", async () => { + const logged = await loggedApp({ + error_handler: async (context: Context): Promise => { + context.response.write("partial"); + context.response.destroy(); + await sleep(5); + }, + }); + try { + logged.app.use(() => { + throw new BindenError(400); + }); + + const outcome = await fetch(logged.url, { + signal: AbortSignal.timeout(10_000), + }) + .then(async (response) => response.text()) + .then( + () => "complete", + (error: unknown) => (error as Error).name, + ); + ok(outcome !== "complete" && outcome !== "AbortError", outcome); + await pollUntil(() => + logged + .output() + .includes( + "The response has been destroyed: nothing replaces the reply", + ), + ).catch(() => null); + ok( + logged + .output() + .includes( + "The response has been destroyed: nothing replaces the reply", + ), + logged.output(), + ); + 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(10_000) })); + await pollUntil(() => + logged.output().includes("The error reply has not been sent"), + ).catch(() => null); + 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(); + app.use(m1, m2); + const response = await fetch(url); + deepStrictEqual(response.status, 200); + const text = await response.text(); + deepStrictEqual(text, m2.id); + }); + + test("auto_head", async () => { + const m = new SendMiddleware(); + app.use("/", new Router().get(m)); + const response = await fetch(url, { method: "HEAD" }); + 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("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); + app.use("/", new Router().get(m)); + const response = await fetch(url); + const data = await response.json(); + deepStrictEqual(response.status, 200); + deepStrictEqual(data, message); + }); + + test("Middleware `ignore_errors === true`", async () => { + const m1 = new SendMiddleware(); + const m2 = new ErrorMiddleware({ ignore_errors: true }); + app.use(m2, m1); + const response = await fetch(url); + deepStrictEqual(response.status, 200); + const text = await response.text(); + 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("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, host: "127.0.0.1", 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); + 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(10_000); + + 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(10_000); + + deepStrictEqual( + await (await fetch(`${url}/send`, { signal })).text(), + "ab", + ); + deepStrictEqual( + await (await fetch(`${url}/pipe`, { signal })).text(), + "ab", + ); + }); + + 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(10_000); + + 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(10_000) }); + 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) => { + 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" }); + deepStrictEqual(response.status, 404); + deepStrictEqual(response.headers.get("Allow"), null); + }); + + test("405 error (`guarded` router)", async () => { + const m = new CustomMiddleware(); + const router = new Router({ guarded: true }).patch(m).get(m); + app.use(router); + const response = await fetch(url, { method: "POST" }); + deepStrictEqual(response.status, 405); + deepStrictEqual(response.headers.get("Allow"), "PATCH, GET, HEAD, OPTIONS"); + }); + + 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, OPTIONS", + ]); + }); + + test("405 error (`guarded` router `auto_head === false`)", async () => { + const newApp = new Binden({ auto_head: false }); + const path = new URL(url); + + const newServer = newApp.createServer(); + + await new Promise((resolve) => { + newServer.listen(0, "127.0.0.1", resolve); + }); + path.port = `${portOf(newServer)}`; + + const m = new CustomMiddleware(); + const router = new Router({ guarded: true }).patch(m).get(m); + newApp.use(router); + const response = await fetch(path.toString(), { method: "POST" }); + deepStrictEqual(response.status, 405); + deepStrictEqual(response.headers.get("Allow"), "PATCH, GET, OPTIONS"); + + await new Promise((resolve) => { + newServer.close(resolve); + }); + }); + + suite("Error handler", () => { + test("custom `error_handler`", async () => { + function error_handler(context: Context): void { + context.response + .status(401) + .setHeader("X-HEADER", "Value") + .end("Secret"); + } + const newApp = new Binden({ error_handler }); + const path = new URL(url); + + const newServer = newApp.createServer(); + + await new Promise((resolve) => { + newServer.listen(0, "127.0.0.1", resolve); + }); + path.port = `${portOf(newServer)}`; + + const status = 405; + const message = "Hello World!"; + const expose = true; + newApp.use(new ErrorMiddleware({}, status, { message, expose })); + + const response = await fetch(path.toString()); + deepStrictEqual(response.status, 401); + deepStrictEqual(response.headers.get("X-HEADER"), "Value"); + + const text = await response.text(); + deepStrictEqual(text, "Secret"); + + await new Promise((resolve) => { + newServer.close(resolve); + }); + }); + + test("errorHadler (default error code when no `status`)", async () => { + const status = 405; + const message = "Hello World!"; + const expose = true; + + class EM extends ErrorMiddleware { + public run(): Promise { + throw { expose, message }; + } + } + + app.use(new EM({}, status, { message, expose })); + + const response = await fetch(url); + + deepStrictEqual(response.status, DefaultErrorCode); + deepStrictEqual( + response.headers.get("Content-Type"), + `${ct_text}; charset=utf-8`, + ); + + const received = await response.text(); + deepStrictEqual(received, message); + }); + + test("errorHadler (`message` is not a string)", async () => { + const status = 405; + const message = 1; + const expose = true; + + const regexp = new RegExp("/", "u"); + + class EM extends ErrorMiddleware { + public run(): Promise { + throw { expose, message }; + } + } + + app.use(regexp, new EM({}, status, { message: "2", expose })); + + const response = await fetch(url); + + deepStrictEqual(response.status, DefaultErrorCode); + deepStrictEqual(response.headers.get("Content-Type"), null); + + const received = await response.text(); + deepStrictEqual(received, ""); + }); + + test("errorHadler (`expose === true`)", async () => { + const status = 405; + const message = "Hello World!"; + const expose = true; + app.use(new ErrorMiddleware({}, status, { message, expose })); + + const response = await fetch(url); + + deepStrictEqual(response.status, status); + deepStrictEqual( + response.headers.get("Content-Type"), + `${ct_text}; charset=utf-8`, + ); + + const received = await response.text(); + + deepStrictEqual(received, message); + }); + + test("errorHadler (`expose === false`)", async () => { + const status = 405; + const message = "Hello World!"; + const expose = false; + app.use(new ErrorMiddleware({}, status, { message, expose })); + + const response = await fetch(url); + + deepStrictEqual(response.status, status); + deepStrictEqual(response.headers.get("Content-Type"), null); + + const received = await response.text(); + + deepStrictEqual(received, ""); + }); + + test("errorHadler (`json`)", async () => { + const status = 405; + const message = "Something happend"; + const json = { message: "Hello World!" }; + const expose = true; + app.use(new ErrorMiddleware({}, status, { message, json, expose })); + + const response = await fetch(url); + + deepStrictEqual(response.status, status); + deepStrictEqual(response.headers.get("Content-Type"), ct_json); + + const received = await response.json(); + + deepStrictEqual(received, json); + }); + + test("errorHadler (the representation of the body that it replaces)", async () => { + const status = 405; + // Sent as text, never as the HTML of the route + const message = ""; + const json = { error: "invalid" }; + class CTMiddleware extends Middleware { + public run(context: Context): void { + context.response.setHeader("Content-Type", ct_html); + context.response.setHeader("Content-Language", "en"); + context.response.setHeader("Content-Location", "/page.html"); + context.response.setHeader("Repr-Digest", "sha-256=:AAAA:"); + } + } + const options: Record = { + "/message": { message, expose: true }, + "/json": { message, json, expose: true }, + "/hidden": { message }, + }; + app.use(new CTMiddleware(), (context: Context) => { + throw new BindenError(status, options[context.url.pathname]); + }); + + const expected: Record = { + "/message": [`${ct_text}; charset=utf-8`, message], + "/json": [ct_json, JSON.stringify(json)], + "/hidden": [null, ""], + }; + for (const [path, [type, body]] of Object.entries(expected)) { + const response = await fetch(`${url}${path}`); + + deepStrictEqual(response.status, status); + deepStrictEqual(response.headers.get("Content-Type"), type); + for (const name of [ + "Content-Language", + "Content-Location", + "Repr-Digest", + ]) { + deepStrictEqual(response.headers.get(name), null, name); + } + deepStrictEqual(await response.text(), body); + } + }); + + test("errorHadler (invalid `json`)", async () => { + const status = 405; + const message = "Something happend"; + const json = { message: 1n }; + const expose = true; + app.use(new ErrorMiddleware({}, status, { message, json, expose })); + + const response = await fetch(url); + + deepStrictEqual(response.status, status); + deepStrictEqual(response.headers.get("Content-Type"), null); + + const received = await response.text(); + + deepStrictEqual(received, ""); + }); + + test(".errorHandler() (`headersSent === true`)", async () => { + const message = ""; + const html = "text/html"; + + class WM extends Middleware { + public run(context: Context): Promise { + return new Promise((resolve, reject) => { + context.response + .set({ "Content-Type": html }) + .status(201) + .write(message, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + } + class EM extends ErrorMiddleware { + public run(context?: Context): Promise { + deepStrictEqual(context?.response.writableEnded, false); + deepStrictEqual(context.response.headersSent, true); + throw this.error; + } + } + + app.use(new WM(), new EM()); + + const response = await fetch(url, { + signal: AbortSignal.timeout(10_000), + }); + + deepStrictEqual(response.status, 201); + deepStrictEqual(response.headers.get("Content-Type"), html); + + // The connection is closed: a truncated body must not look complete + await rejects( + response.text(), + (error: Error) => error.name !== "AbortError", + ); + }); + + 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(10_000), + }); + + 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(10_000), + }); + + 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 () => { + const expose = true; + const status = 200; + const message = ""; + const html = "text/html"; + const error = new BindenError(500, { expose, message }); + + class WM extends Middleware { + public run(context: Context): Promise { + return new Promise((resolve) => { + context.response + .set({ "Content-Type": html }) + .status(status) + .end(message, resolve); + }); + } + } + class EM extends Middleware { + public run(context: Context): Promise { + deepStrictEqual(context.response.writableEnded, true); + throw error; + } + } + + app.use(new WM(), new EM()); + + const response = await fetch(url); + + deepStrictEqual(response.status, status); + deepStrictEqual(response.headers.get("Content-Type"), html); + + const received = await response.text(); + + deepStrictEqual(received, message); + }); + }); + + 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/brands.test.ts b/packages/binden/test/brands.test.ts new file mode 100644 index 00000000..7998bc17 --- /dev/null +++ b/packages/binden/test/brands.test.ts @@ -0,0 +1,206 @@ +import { + Binden, + BindenRequest, + BindenResponse, + Context, + serializers, +} from "../index.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import type { ILogger } from "@binden/logger"; +import { Socket } from "node:net"; +import { createServer } from "node:http"; +import { runInNewContext } from "node:vm"; + +/** A value of any type, given in place of the one that a method declares */ +function given(value: unknown): T { + return value as T; +} + +/** What `new Context()` throws for its options (or the context it creates) */ +function refusal(options: object): Context | [string, string] { + try { + return new Context(given(options)); + } catch (error: unknown) { + const { name, message } = error as Error; + return [name, message]; + } +} + +/** An object made with `Object.create()` of the prototype of a class */ +function fabricated(type: { readonly prototype: object }): object { + return Object.create(type.prototype) as object; +} + +/** The same, made in another realm */ +function foreign(type: { readonly prototype: object }): object { + return runInNewContext("Object.create(prototype)", { + prototype: type.prototype, + }) as object; +} + +/** A proxy that records the traps that run (none of which changes anything) */ +function recording(target: T, traps: string[]): T { + const handler = new Proxy( + {}, + { + get(_handler: object, trap: string): null { + traps.push(trap); + return null; + }, + }, + ); + return new Proxy(target, handler); +} + +suite( + "binden tells its requests, responses and contexts by their private brands (R28-C2)", + () => { + test("new Context() refuses an object made with Object.create() of a prototype of binden, of another realm, or a proxy, without running a trap", () => { + const request = new BindenRequest(new Socket()); + const response = new BindenResponse(request); + const traps: string[] = []; + deepStrictEqual( + [ + refusal({ request: fabricated(BindenRequest), response }), + // It was accepted, and crashed the process at the next tick + refusal({ request, response: fabricated(BindenResponse) }), + refusal({ + request: fabricated(BindenRequest), + response: fabricated(BindenResponse), + }), + refusal({ request: foreign(BindenRequest), response }), + refusal({ request, response: foreign(BindenResponse) }), + refusal({ request: recording(request, traps), response }), + refusal({ request, response: recording(response, traps) }), + traps, + ], + [ + ["TypeError", "`request` is not a BindenRequest: (an object)"], + ["TypeError", "`response` is not a BindenResponse: (an object)"], + ["TypeError", "`request` is not a BindenRequest: (an object)"], + ["TypeError", "`request` is not a BindenRequest: (an object)"], + ["TypeError", "`response` is not a BindenResponse: (an object)"], + ["TypeError", "`request` is not a BindenRequest: (an object)"], + ["TypeError", "`response` is not a BindenResponse: (an object)"], + [], + ], + ); + }); + + test("new Context() accepts the instances of subclasses (also with frozen prototypes), and those of the classes of an application", () => { + class Request extends BindenRequest {} + class Response extends BindenResponse {} + Object.freeze(Request.prototype); + Object.freeze(Response.prototype); + const request = new Request(new Socket()); + const response = new Response(request); + const context = new Context({ request, response }); + ok(context.request === request && context.response === response); + const { IncomingMessage: AppRequest, ServerResponse: AppResponse } = + new Binden().serverOptions(); + const own = new AppRequest(new Socket()); + const reply = new AppResponse(own); + const created = new Context({ request: own, response: reply }); + ok(created.request === own && created.response === reply); + }); + + test("the serializer of a context describes by its type an object made with Object.create() of the prototype of Context, and a proxy of one: a subclass is serialized", () => { + const request = new BindenRequest(new Socket()); + request.method = "GET"; + request.url = "/path?query=secret"; + const response = new BindenResponse(request); + class Derived extends Context {} + const context = new Derived({ request, response }); + deepStrictEqual( + [ + serializers.context(fabricated(Context)), + serializers.context(foreign(Context)), + serializers.context(new Proxy(context, {})), + serializers.context(context), + ], + [ + { type: "object" }, + { type: "object" }, + { type: "object" }, + { + id: context.id, + request: { method: "GET", path: "/path" }, + response: { status: 200 }, + }, + ], + ); + }); + + test("the listener of callback() refuses an object made with Object.create() of the prototype of a class of the application, and an instance of a class of another one; it handles the instances of their subclasses", async () => { + const errors: string[] = []; + const ignore = (): null => null; + const logger: ILogger = { + fatal: ignore, + error: (message: string): void => { + errors.push(message); + }, + warn: ignore, + info: ignore, + debug: ignore, + trace: ignore, + child: (): ILogger => logger, + }; + const app = new Binden({ logger }).use((context) => + context.send("handled"), + ); + const listener = app.callback(createServer()); + const { IncomingMessage: Request, ServerResponse: Response } = + app.serverOptions(); + const other = new Binden().serverOptions(); + const destroyed: boolean[] = []; + for (const kind of ["fabricated", "other request", "other response"]) { + const socket = new Socket(); + const request = + kind === "other request" + ? new other.IncomingMessage(socket) + : new Request(socket); + const response = + kind === "fabricated" + ? given(fabricated(Response)) + : kind === "other response" + ? new other.ServerResponse(request) + : new Response(request); + listener(request, response); + destroyed.push(socket.destroyed); + } + deepStrictEqual( + [destroyed, errors], + [ + [true, true, true], + Array.from( + { length: 3 }, + () => + "The request and the response are not those of the application (`serverOptions()`)", + ), + ], + ); + + class SubRequest extends Request {} + class SubResponse extends Response {} + const classes: ReturnType = { + IncomingMessage: SubRequest, + ServerResponse: given(SubResponse), + }; + const listening = await listen({ + createServer: () => { + const server = createServer(classes); + server.on("request", app.callback(server)); + return server; + }, + }); + try { + const { status, body } = await within(send(listening.port)); + deepStrictEqual([status, body, errors.length], [200, "handled", 3]); + } finally { + await listening.close(); + } + }); + }, +); diff --git a/packages/binden/test/cancel-rights.test.ts b/packages/binden/test/cancel-rights.test.ts new file mode 100644 index 00000000..138a274b --- /dev/null +++ b/packages/binden/test/cancel-rights.test.ts @@ -0,0 +1,95 @@ +import { Binden, type Context } from "../index.js"; +import { capture, listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** + * A source that never produces anything, whose removal of a listener (by its + * own `removeListener()`, or by a listener of its `removeListener` event) + * runs `hijack` + */ +function hijacking(how: "event" | "override", hijack: () => void): Readable { + const read = (): void => { + // Never read + }; + if (how === "event") { + const source = new Readable({ read }); + source.on("removeListener", hijack); + return source; + } + return new (class extends Readable { + public override removeListener( + ...args: Parameters + ): this { + hijack(); + return super.removeListener(...args); + } + })({ read }); +} + +suite("A pending send that the framework cancels", () => { + for (const how of ["override", "event"] as const) { + for (const [by, replied] of [ + ["the deadline", 503], + ["the failure of the error handler", 500], + ] as const) { + test(`(${by}) removes the listeners of its source without the rights of the framework: the removal (${how === "event" ? "a `removeListener` listener" : "its own `removeListener()`"}) cannot replace the reply`, async () => { + const failure = new Error("The error handler has failed"); + /** Whether each attempt to replace the reply has ended the response */ + const attempts: unknown[] = []; + const outcome = Promise.withResolvers(); + /** Sends a source whose removal of a listener replaces the reply */ + const sending = (context: Context): Promise => { + const { response } = context; + const source = hijacking(how, () => { + if (attempts.length === 0) { + response.statusCode = 299; + response.end("Hijacked"); + attempts.push(response.writableEnded); + } + }); + return context.send(source).then( + () => "resolved", + (error: unknown) => error, + ); + }; + const app = + by === "the deadline" + ? new Binden({ logger: capture().logger, timeout: 50 }).use( + async (context: Context) => { + outcome.resolve(await sending(context)); + }, + ) + : new Binden({ + logger: capture().logger, + error_handler: (context: Context): never => { + outcome.resolve(sending(context)); + throw failure; + }, + }).use(() => { + throw new Error("A failure"); + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port, { ms: 10_000 }); + const reason = await within(outcome.promise); + // Its attempt has changed nothing + deepStrictEqual( + [ + status, + body, + by === "the deadline" + ? (reason as { reason?: unknown }).reason + : reason === failure, + attempts, + ], + [replied, "", by === "the deadline" ? "deadline" : true, [false]], + ); + } finally { + await listening.close(); + } + }); + } + } +}); diff --git a/packages/binden/test/cancelled-errors.test.ts b/packages/binden/test/cancelled-errors.test.ts new file mode 100644 index 00000000..252a5ccb --- /dev/null +++ b/packages/binden/test/cancelled-errors.test.ts @@ -0,0 +1,108 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { + type ICaptured, + capture, + ioTurn, + listen, + never, + send, + within, +} from "./helpers.js"; +import { suite, test } from "node:test"; +import { PassThrough } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** The error that the source of the send brings while its cancellation is pending */ +const brought = "The source has failed meanwhile"; + +/** The messages of the late errors that have been logged */ +function late(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +/** + * Replies one request of the application: the status that the client gets, + * and the messages of the late errors that have been logged once the send + * has settled, on a later turn + */ +async function replied( + app: Binden, + captured: ICaptured, + settled: Promise, +): Promise<[number, unknown[]]> { + const listening = await listen(app); + try { + const { status } = await send(listening.port); + await within(settled); + // A late error is logged on a later turn + await ioTurn(); + return [status, late(captured)]; + } finally { + await listening.close(); + } +} + +suite( + "An error that the source of a send brings once the cancellation of the send is recorded, before it is carried out, decides nothing: the send rejects with the reason of the cancellation, and that error is logged once, as a late error", + () => { + test("of a failed error handler", async () => { + const reason = new Error("The error handler has failed"); + const outcome = Promise.withResolvers(); + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + error_handler: (context: Context): never => { + const source = new PassThrough(); + context.send(source).then(outcome.resolve, outcome.resolve); + // Once the failure of the handler has recorded the cancellation of + // its sends, before it is carried out + process.nextTick(() => { + source.destroy(new Error(brought)); + }); + throw reason; + }, + }).use(() => { + throw new Error("The middleware has failed"); + }); + const [status, logged] = await replied(app, captured, outcome.promise); + deepStrictEqual( + [status, await outcome.promise, logged], + [500, reason, [brought]], + ); + }); + + test("of a failed pipe", async () => { + const reason = new Error("The pipe has failed"); + const outcome = Promise.withResolvers(); + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const source = new PassThrough(); + context.send(source).then(outcome.resolve, outcome.resolve); + // Told that its response is closed once the pipe below has failed, + // which has recorded the cancellation of its sends, before the + // framework carries it out + context.response.on("close", () => { + source.destroy(new Error(brought)); + }); + const piped = new PassThrough(); + piped.pipe(context.response); + piped.prependListener("end", () => { + throw reason; + }); + piped.end(); + await never(); + }); + const [status, logged] = await replied(app, captured, outcome.promise); + deepStrictEqual( + [status, await outcome.promise, logged], + [500, reason, [brought]], + ); + }); + }, +); diff --git a/packages/binden/test/cancelled-rollbacks.test.ts b/packages/binden/test/cancelled-rollbacks.test.ts new file mode 100644 index 00000000..900cf2ce --- /dev/null +++ b/packages/binden/test/cancelled-rollbacks.test.ts @@ -0,0 +1,428 @@ +import { + Binden, + BindenResponse, + type Context, + type IRepresentation, + type IRepresentationNegotiator, + kNegotiator, +} from "../index.js"; +import { Duplex, PassThrough, Readable } from "node:stream"; +import { after, afterEach, before, mock, suite, test } from "node:test"; +import { capture, listen, never, send, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import fsp, { type FileHandle, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { kCancel, kCancelling, kSeal, owners } from "../src/seal.js"; +import { IncomingMessage } from "node:http"; +import type { ReadStream } from "node:fs"; +import type { Socket } from "node:net"; +import { join } from "node:path"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; + +/** Long enough never to expire on a loaded machine: it only guards against a hang */ +const deadline = 10_000; + +/** A response to `GET` (with the headers of its request) on a connection that records what it is sent */ +function detached(headers: Record = {}): { + response: BindenResponse; + /** What the connection has been sent, without the `Date` */ + sent(): string; + close(): void; +} { + const chunks: Buffer[] = []; + const wire = new Duplex({ + read(): void { + // The client sends nothing more + }, + write(chunk: Buffer, _encoding, done): void { + chunks.push(chunk); + done(); + }, + }); + const request = new IncomingMessage(wire as unknown as Socket); + Object.assign(request, { + method: "GET", + headers, + httpVersionMajor: 1, + httpVersionMinor: 1, + }); + const response = new BindenResponse(request); + response.assignSocket(wire as unknown as Socket); + return { + response, + sent: () => + Buffer.concat(chunks) + .toString("latin1") + .replaceAll(/\r\nDate: [^\r]*/gu, ""), + close: (): void => { + wire.destroy(); + }, + }; +} + +/** The settled value of a promise: `null` once resolved, or its error */ +function settled(promise: Promise): Promise { + return within( + promise.then( + () => null, + (error: unknown) => error, + ), + deadline, + ); +} + +/** + * Lets `respond` act once a send of a `Readable` has started (e.g. the send + * of the file of a `sendFile()`), right after it has + */ +function onPipe( + response: BindenResponse, + respond: (source: Readable) => void, +): void { + const original = response.send.bind(response) as ( + ...args: unknown[] + ) => Promise; + response.send = (...args: unknown[]): Promise => { + const sent = original(...args); + const [data] = args; + if (data instanceof Readable) { + respond(data); + } + return sent; + }; +} + +/** A negotiator of the body as it is, which counts how often its choice is forgotten */ +class Negotiator implements IRepresentationNegotiator { + public forgotten = 0; + + // eslint-disable-next-line class-methods-use-this -- the body as it is + public choose(): IRepresentation { + return { coding: null, status: null }; + } + + public forget(): void { + this.forgotten += 1; + } +} + +/** The headers that a `sendFile()` of a range sets */ +const fileHeaders = [ + "accept-ranges", + "content-length", + "content-range", + "content-type", + "etag", + "last-modified", + "x-content-type-options", +]; + +suite("A cancellation recorded before a send settles", () => { + let tmp = ""; + let file = ""; + + before(async () => { + tmp = await mkdtemp(join(tmpdir(), "binden-rollbacks-")); + file = join(tmp, "window.pdf"); + await writeFile(file, "12345678"); + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + afterEach(() => { + mock.restoreAll(); + syncBuiltinESMExports(); + }); + + test("a sendFile() whose send of its file fails before the cancellation runs is restored all the same: the reply that replaces it has the status and the headers from before it", async () => { + const connection = detached({ range: "bytes=0-1" }); + const { response } = connection; + try { + const reason = new Error("The pipe has failed"); + response.setHeader("X-Before", "kept"); + // Recorded while the file is sent, which then fails (e.g. a read + // error), and its `sendFile()` settles before the cancellation runs + onPipe(response, (source) => { + response[kCancelling](reason); + source.destroy(new Error("The file has failed")); + }); + deepStrictEqual(await settled(response.sendFile(file)), reason); + // Settled: not yet restored + deepStrictEqual(response.statusCode, 206); + ok(!response.started); + response[kCancel](reason); + deepStrictEqual( + [response.statusCode, response.getHeaderNames().sort()], + [200, ["x-before"]], + ); + await within(response.send("FAILED"), deadline); + const head = connection.sent(); + ok(head.startsWith("HTTP/1.1 200 OK\r\n"), head); + ok(/^X-Before: kept\r$/mu.test(head), head); + ok(/^Content-Type: text\/plain\r$/mu.test(head), head); + ok( + !/^(?:ETag|Content-Range|Last-Modified|Accept-Ranges):/imu.test(head), + head, + ); + ok(head.endsWith("\r\n\r\nFAILED"), head); + } finally { + connection.close(); + } + }); + + test("a sendFile() that settles as ignored before the cancellation runs (its work revoked) is restored all the same, once: the status and the headers set after it are removed, not those of the reply that replaces it", async () => { + const connection = detached(); + const { response } = connection; + try { + const work = {}; + const replacement = {}; + let revoked = false; + response[kSeal]( + (owner) => owner === replacement || (owner === work && !revoked), + ); + const reason = new Error("The error handler has failed"); + const sending = owners.run(work, () => { + const pending = response.sendFile(file); + response.status(201).setHeader("X-After", "set"); + return pending; + }); + // Before its file is opened: it sees that its work may no longer + // mutate the response, and settles (as an ignored send would) + revoked = true; + response[kCancelling](reason); + deepStrictEqual(await settled(sending), reason); + deepStrictEqual(response.statusCode, 201); + response[kCancel](reason); + deepStrictEqual( + [response.statusCode, response.hasHeader("X-After")], + [200, false], + ); + await owners.run(replacement, async () => { + response.status(500).setHeader("X-Replacement", "kept"); + // E.g. a deadline that cancels the sends of the reply that replaces + // it: restored once, it is not restored again + response[kCancel](new Error("The deadline has passed")); + deepStrictEqual( + [response.statusCode, response.getHeader("X-Replacement")], + [500, "kept"], + ); + await within(response.send("FAILED"), deadline); + }); + const head = connection.sent(); + ok(head.startsWith("HTTP/1.1 500 Internal Server Error\r\n"), head); + ok(/^X-Replacement: kept\r$/mu.test(head), head); + ok(!/^X-After:/imu.test(head), head); + } finally { + connection.close(); + } + }); + + test("a sendFile() whose send of its file fails without the rights of its revoked work before the cancellation runs is restored all the same: the representation chosen for the file is forgotten", async () => { + const connection = detached({ range: "bytes=0-1" }); + const { response } = connection; + try { + const work = {}; + const replacement = {}; + let revoked = false; + response[kSeal]( + (owner) => owner === replacement || (owner === work && !revoked), + ); + const negotiator = new Negotiator(); + response[kNegotiator](negotiator); + const reason = new Error("The pipe has failed"); + onPipe(response, (source) => { + revoked = true; + response[kCancelling](reason); + source.destroy(new Error("The file has failed")); + }); + const sending = owners.run(work, () => response.sendFile(file)); + deepStrictEqual(await settled(sending), reason); + // Its send has failed as its work, which may no longer forget + deepStrictEqual([response.statusCode, negotiator.forgotten], [206, 0]); + response[kCancel](reason); + deepStrictEqual( + [ + response.statusCode, + negotiator.forgotten, + fileHeaders.filter((name) => response.hasHeader(name)), + ], + [200, 1, []], + ); + await owners.run(replacement, () => + within(response.send("FAILED"), deadline), + ); + const head = connection.sent(); + ok(head.startsWith("HTTP/1.1 200 OK\r\n"), head); + ok( + !/^(?:ETag|Content-Range|Content-Type: application)/imu.test(head), + head, + ); + } finally { + connection.close(); + } + }); + + for (const settles of [false, true]) { + test(`a send of a stream ${settles ? "that fails before the cancellation runs" : "still in progress when the cancellation runs"} is restored without the framing set since it has started`, async () => { + const connection = detached(); + const { response } = connection; + try { + const work = {}; + const replacement = {}; + let revoked = false; + response[kSeal]( + (owner) => owner === replacement || (owner === work && !revoked), + ); + const reason = new Error("The pipe has failed"); + const source = new PassThrough(); + const sending = owners.run(work, () => { + response.setHeader("Content-Length", 8); + const piped = response.send(source); + // Framed again once it has started: its own failure leaves that + // framing (to the reply that has set it), its cancellation does not + response.setHeader("Content-Range", "bytes 0-7/16"); + return piped; + }); + revoked = true; + response[kCancelling](reason); + if (settles) { + source.destroy(new Error("The source has failed")); + deepStrictEqual(await settled(sending), reason); + ok(response.hasHeader("Content-Range")); + } + response[kCancel](reason); + deepStrictEqual(await settled(sending), reason); + deepStrictEqual( + ["content-length", "content-range"].filter((name) => + response.hasHeader(name), + ), + [], + ); + await owners.run(replacement, () => + within(response.send("FAILED"), deadline), + ); + const head = connection.sent(); + ok(head.startsWith("HTTP/1.1 200 OK\r\n"), head); + ok(/^Content-Length: 6\r$/mu.test(head), head); + ok(!/^Content-Range:/imu.test(head), head); + } finally { + connection.close(); + } + }); + } + + test("in an application, a sendFile() of the stack whose file fails once a pipe of the stack has failed is restored before the error handler replies: without the representation chosen for the file", async () => { + /** The streams of the files, whose first chunk never comes */ + const streams: PassThrough[] = []; + const created = Promise.withResolvers(); + const { open } = fsp; + mock.method( + fsp, + "open", + async (...args: Parameters): Promise => { + const handle = await open(...args); + const create = handle.createReadStream.bind(handle); + handle.createReadStream = ( + ...options: Parameters + ): ReadStream => { + // Closes the file once it is destroyed (it reads nothing) + const stream = create(...options); + const proxy = new PassThrough(); + proxy.once("close", () => { + stream.destroy(); + }); + streams.push(proxy); + created.resolve(null); + return proxy as unknown as ReadStream; + }; + return handle; + }, + ); + syncBuiltinESMExports(); + const negotiator = new Negotiator(); + const failure = new Error("The pipe has failed"); + const sending = Promise.withResolvers(); + /** How many times the choice was forgotten once the error handler runs */ + let forgotten: number | null = null; + const app = new Binden({ + logger: capture().logger, + error_handler: async (context: Context): Promise => { + ({ forgotten } = negotiator); + await context.response.status(500).send("failed"); + }, + }); + app.use(async (context: Context) => { + context.response[kNegotiator](negotiator); + // Told that its response is closed once the pipe below has failed, + // before the framework cancels its sends: its file fails then + context.response.on("close", () => { + streams[0]?.destroy(new Error("The file has failed")); + }); + settled(context.response.sendFile(file)).then( + sending.resolve, + sending.reject, + ); + // Once its file is sent + await within(created.promise, deadline); + const source = new PassThrough(); + source.pipe(context.response); + source.prependListener("end", () => { + throw failure; + }); + source.end(); + await never(); + }); + const listening = await listen(app); + try { + const received = await send(listening.port, { ms: deadline }); + deepStrictEqual( + [ + received.status, + received.body, + fileHeaders.filter((name) => name in received.headers), + ], + [500, "failed", ["content-length", "content-type"]], + ); + deepStrictEqual(received.headers["content-type"], "text/plain"); + } finally { + await listening.close(); + } + deepStrictEqual(forgotten, 1); + deepStrictEqual(await within(sending.promise, deadline), failure); + }); + + test("a send that has settled before its cancellation is recorded is not restored: what is set since stays", async () => { + const connection = detached(); + const { response } = connection; + try { + const reason = new Error("The pipe has failed"); + const missing = join(tmp, "missing.pdf"); + const failure = (await settled(response.sendFile(missing))) as { + code?: unknown; + }; + deepStrictEqual(failure.code, "ENOENT"); + const source = new PassThrough(); + const streamed = response.send(source); + source.destroy(new Error("The source has failed")); + ok((await settled(streamed)) instanceof Error); + response.status(404).set({ + "Content-Range": "bytes */16", + "X-After": "kept", + }); + response[kCancelling](reason); + response[kCancel](reason); + deepStrictEqual( + [ + response.statusCode, + response.getHeader("Content-Range"), + response.getHeader("X-After"), + ], + [404, "bytes */16", "kept"], + ); + } finally { + connection.close(); + } + }); +}); diff --git a/packages/binden/test/carried-values.test.ts b/packages/binden/test/carried-values.test.ts new file mode 100644 index 00000000..0725b953 --- /dev/null +++ b/packages/binden/test/carried-values.test.ts @@ -0,0 +1,144 @@ +import { Binden, type Context } from "../index.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, + uncarried, +} from "../src/stream-like.js"; +import { capture, ioTurn, listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** An error whose `stack` getter throws: Node.js cannot read it safely */ +function unstackable(message: string): Error { + return Object.defineProperty(new Error(message), "stack", { + get(): never { + throw new Error("Its stack cannot be read"); + }, + }); +} + +/** + * A stream-like source of its own whose `resume()` (the first read of its + * readable) throws a value, and that records what it is destroyed with + */ +class Source extends EventEmitter { + public readonly destroys: unknown[] = []; + readonly #thrown: Error | null; + + public constructor(thrown: Error | null = null) { + super(); + this.#thrown = thrown; + } + + public pause(): this { + return this; + } + + public resume(): this { + if (this.#thrown !== null) { + throw this.#thrown; + } + return this; + } + + public destroy(error?: Error): this { + this.destroys.push(error); + return this; + } +} + +/** The readable of a stream-like source (see `readStreamLike()`) */ +function readableOf(source: Source): Readable { + return readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + ); +} + +/** The error that a readable is destroyed with */ +function failureOf(readable: Readable): Promise { + return within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); +} + +suite( + "A carrier is taken for its value only by the readable that has destroyed itself with it (R24-C1)", + () => { + test("uncarried(): the value for the readable of the carrier, the carrier itself for another readable of a stream-like source (also one with carriers of its own) and for a Node.js Readable", async () => { + const value = unstackable("First"); + const first = readableOf(new Source(value)); + const other = readableOf(new Source(unstackable("Other"))); + const carriers = [failureOf(first), failureOf(other)]; + first.resume(); + other.resume(); + const [carrier, own] = (await Promise.all(carriers)) as [Error, Error]; + await ioTurn(); + const native = new Readable({ read: (): null => null }); + deepStrictEqual( + [ + carrier.cause === value, + uncarried(carrier, first) === value, + uncarried(carrier, other) === carrier, + uncarried(own, other) === own.cause, + uncarried(carrier, native) === carrier, + uncarried(value, first) === value, + uncarried(null, first), + ], + [true, true, true, true, true, true, null], + ); + native.destroy(); + }); + + test("kept from the destroy() of a source, and emitted by the source of another send (a Node.js Readable, or a stream-like source): that send rejects with the carrier, not with the value of the first source", async () => { + for (const kind of ["native", "stream-like"] as const) { + const value = unstackable("First source failure"); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + const first = new Source(value); + const firstError = await context.send(first).then( + () => "resolved", + (error: unknown) => error, + ); + const [carrier] = first.destroys as [Error]; + const second = + kind === "native" + ? new Readable({ read: (): null => null }) + : new Source(); + const sent = context.send(second); + second.emit("error", carrier); + const secondError = await sent.then( + () => "resolved", + (error: unknown) => error, + ); + outcome.resolve([ + firstError === value, + carrier.cause === value, + secondError === carrier, + ]); + context.response.status(500).end("failed"); + }, + ); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + deepStrictEqual( + [...(await within(outcome.promise)), status, body], + [true, true, true, 500, "failed"], + kind, + ); + } finally { + await listening.close(); + } + } + }); + }, +); diff --git a/packages/binden/test/carrier-stacks.test.ts b/packages/binden/test/carrier-stacks.test.ts new file mode 100644 index 00000000..34015c81 --- /dev/null +++ b/packages/binden/test/carrier-stacks.test.ts @@ -0,0 +1,141 @@ +import { carrierOf, readsSafely } from "../src/exchange.js"; +import { emitterMethods, guard } from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** The ways of `fixtures/carrier-stacks.ts` */ +const ways = [ + "emit", + "pause", + "resume", + "node-pause", + "rs4", + "rs3", + "redefine", +] as const; + +/** + * What the fixture prints for each way: the response is replied (`500`, of + * the error that the work throws once the send has rejected) unless its + * headers are committed (cut), and the source is destroyed once: with its + * carrier, without an error once it has emitted its value (`emit`), or by the + * application itself (`rs4`, `rs3`); the `_destroy()` of `redefine` cannot + * redefine the `stack` of the carrier + */ +const expected: Readonly> = { + emit: "replied rejected:V 500 destroys=none", + pause: "cut rejected:V destroys=carrier", + resume: "replied rejected:V 500 destroys=carrier", + "node-pause": "cut rejected:V destroys=carrier", + rs4: "cut rejected:V destroys=V", + rs3: "cut rejected:V destroys=V", + redefine: "replied rejected:V 500 destroys=carrier:refused", +}; + +/** + * Runs `fixtures/carrier-stacks.ts` in a process of its own, without a + * handler of the process: a crash (e.g. a throw into an event of a source) is + * its exit code and output, not a failure of the test runner + */ +function fixture(...args: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "carrier-stacks.ts"), + ...args, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite( + "The carrier of a value whose stack Node.js cannot read safely (H25-1)", + () => { + test("is an Error whose stack is a value of its own, which can be neither written nor redefined, made without running any prepareStackTrace: it is decided, and Node.js reads its stack safely", () => { + const cause = new Error("V"); + /** What the `prepareStackTrace` of the application has been given */ + const given: unknown[] = []; + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: unknown): never => { + given.push(error); + // As one that freezes what it formats, then fails + Object.freeze(error); + throw new Error("Frozen"); + }); + const carrier = ((): Error => { + try { + return carrierOf(cause); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + })(); + const reported: unknown[] = []; + const other = new EventEmitter(); + guard(other, emitterMethods, (error) => reported.push(error)); + other.emit("error", carrier); + deepStrictEqual( + [ + carrier instanceof Error, + Reflect.getPrototypeOf(carrier) === Error.prototype, + Reflect.getOwnPropertyDescriptor(carrier, "message"), + Reflect.getOwnPropertyDescriptor(carrier, "cause"), + Reflect.getOwnPropertyDescriptor(carrier, "stack"), + given, + readsSafely(carrier), + reported, + ], + [ + true, + true, + { + value: "A stream has failed", + writable: true, + enumerable: false, + configurable: true, + }, + { + value: cause, + writable: true, + enumerable: false, + configurable: true, + }, + { + value: "Error: A stream has failed", + writable: false, + enumerable: false, + configurable: false, + }, + [], + true, + [], + ], + ); + }); + + test("while a prepareStackTrace of the application redefines the stack of what it formats as a getter that cannot be configured, or freezes it and throws: the send rejects with the value, the source is destroyed once, nothing is thrown into its events, and the process does not crash", () => { + for (const kind of ["nonconf", "freeze"]) { + deepStrictEqual( + fixture(kind, ...ways), + { + status: 0, + stdout: ways + .map( + (way) => + `${kind}:${way} ${expected[way]} destroyed=true threw=\n`, + ) + .join(""), + stderr: "", + }, + kind, + ); + } + }); + }, +); diff --git a/packages/binden/test/cert/cert.pem b/packages/binden/test/cert/cert.pem new file mode 100644 index 00000000..f759219e --- /dev/null +++ b/packages/binden/test/cert/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOjCCAiKgAwIBAgIUBaob8PL9TPwoDWzTgBMRS2zdtpwwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAxNTA3MTUzNFoYDzIxMjYw +OTIxMDcxNTM0WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDdbaB5QyxLvycbBRKx3xX6hEAwyZjVUpLZL1q5dGGY +m5vR4wSJcdhi+CW3wBNaPr5g5JJedQiJeKRbs13pYNKM5+Lofqp9Llb0HWRk0EGd +zQSJ4CEPQZ+mCdSETWYMwRHKSfc0bVm03ZmWaP7L5Ev9qbW0wREkEQmMY7wIRMP0 +QhCm1IHb+MYhdjYXIia61ggUU8Ii3IU1qzy0VbcR5LjenRRMUvLTktKnHzY1rdDR +TMHZSoNqw3v7Oqa3I9z71tALdwWow1aAlbRfUpoB0THi+ENJF/gXpofJOcx50pZy +sgIkLfrwQG+S4U4Kidk5sVXSmvvYAC/h10ZZbjPiKN7JAgMBAAGjgYEwfzAdBgNV +HQ4EFgQUtyCPtJgiZhxtLd+KPcdTUU8RMDIwHwYDVR0jBBgwFoAUtyCPtJgiZhxt +Ld+KPcdTUU8RMDIwLAYDVR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAA +AAAAAAAAAAABMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBABna +ccwjMwgEuWIj2UC5sE+EBn12N6f/06GykUkhfDtvnkHtsvgcOGVbkiM2dCHoCRR3 +tsccJZ46+u64vkR6/7/0ZcIS5nDbVF+guS9bJu/8DDi46XctqIXlm2jYzp+F0jtl +Y30IrP/8jsiyw3rqf+3qgDTzVVC9Q+aclhtt5AeY+/PbGyZXGDed6bIu5asGMvWk ++glPUnW3x3tqgqB15O3eOcs6HPOPK+pfuvuRB78ltN0Vk9Q1rvvJJNikbipqqbjK +X6/E9cordJrUslW35a4SHq1KkiUhksmj2PWsMXTLCfTcolkvX6zk6+wsTI9+uiwZ +CA5fB756EIZqJyG84/A= +-----END CERTIFICATE----- diff --git a/packages/binden/test/cert/key.pem b/packages/binden/test/cert/key.pem new file mode 100644 index 00000000..e2c5cae4 --- /dev/null +++ b/packages/binden/test/cert/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDdbaB5QyxLvycb +BRKx3xX6hEAwyZjVUpLZL1q5dGGYm5vR4wSJcdhi+CW3wBNaPr5g5JJedQiJeKRb +s13pYNKM5+Lofqp9Llb0HWRk0EGdzQSJ4CEPQZ+mCdSETWYMwRHKSfc0bVm03ZmW +aP7L5Ev9qbW0wREkEQmMY7wIRMP0QhCm1IHb+MYhdjYXIia61ggUU8Ii3IU1qzy0 +VbcR5LjenRRMUvLTktKnHzY1rdDRTMHZSoNqw3v7Oqa3I9z71tALdwWow1aAlbRf +UpoB0THi+ENJF/gXpofJOcx50pZysgIkLfrwQG+S4U4Kidk5sVXSmvvYAC/h10ZZ +bjPiKN7JAgMBAAECggEAH3yZi2zPJXVI4wl5Y1Q4/J7mPtnH8xuzGJGoicMgJrKH ++ZRZ3o6TCd3GBUdZqSVPbplKLAbh4CSI4KZUph1Xy2QlCtEKPtNCV6O/VSudDQqs +iMuXf+Mhx9tRfj/AUXN562VRswGStkqlrrL++nDcEnXZyhqDLLkEfdNn11gp8jdY +CZI6Fu4fcPdv2vKuGQad9unHUKHa5vlxpMIpEJxdHbX+38dP9dyXBy+qDEC8oJU8 +Q/ecTQRuJUEZVJzBqj2hlL/cFV+McG6SXe7k9oXhIpSjpmC2gWlTWVrJzhse8s7z +zgopFHLcfULe5CthehsgCCB5fOT6o1W+TJa/omk/tQKBgQD0HQZF42YxV1E89O/8 +qFkkbkNS2IjSoN1MYVR9jGqnZPdhu4rSlY2p/gXYEzPe/OgGCW63rX88cL2zM1FK +/kwtI/xKOmcY/oyhUcQVZAEolM4L/SYPXUO1/Vo1juLkyp9GVz96L0jLa2Z/fxlO +mT1EY2hT2x/jRdUoSWv1rmNEvQKBgQDoNdKOboJgnyE1My9owpCV5NqfHjuegV+m +dyyo3tv/4Orws7ty5tJyATl5Rv8tc5DAa90+kT8UUZWHF4F/pbm5kDq5h33jqvOl +ftmB1l7x3h0wWmeilHfhwiUuMPBWiJagtx1SgwmtT+YZvGP9lRI1kHM9778UipTw +5kmQELyw/QKBgAoASmqAl4Y7+pMRwedLCmPotI9nWauvdYGUlz6RiykShOQXJ+fa +wrQNOTYSC1T+bY8ZwdR6ojTatZrq5lC5PGu5BrQP0nt+qkpNrEfoEfgUqKBrHa0R +eB2zpId/NCKFfqMGHg6mc0gKPWfJ0EKdGpp7rCGLX7ITAVmmMGsfWufNAoGALB3F +of/Gz1qMMZJG6I1GcOPo0SAqTYPgyzyLBI5Kl8OAfzh2XW8S/kvTQoJFiroQ/y2+ ++9sWc+LIGMFTfg/bSDDpp0Phm/ZnkcKWyj8TDLqZKw2R8OZqHD8JV0w+7QHINEJ1 +1K5GkWHDopITISNIMVr/Of4pfFqTz2zA/xje8HUCgYEAiYYjikl1CukVQTErHuJT +2hq5pY2Plq/Biqe09YffID97zTbV2AbqToSOeT49qOk8vIARoC3K+0XJTbNxkvnM +yxl/dU7XILO+9siLWUkNTVGrAhc/pvsCQp5wL1K8TOwUCdYp5q8mgZV4aKBylecx +gzA8EhJvSjFzVRhX8CapNik= +-----END PRIVATE KEY----- diff --git a/packages/binden/test/close-release.test.ts b/packages/binden/test/close-release.test.ts new file mode 100644 index 00000000..adc20f5d --- /dev/null +++ b/packages/binden/test/close-release.test.ts @@ -0,0 +1,152 @@ +import { + Binden, + BindenResponse, + type Context, + releasedFailure, +} from "../index.js"; +import { Duplex, PassThrough, Readable } from "node:stream"; +import { HOST, capture, ioTurn, listen, within } from "./helpers.js"; +import { type Socket, connect } from "node:net"; +import { suite, test } from "node:test"; +import { IncomingMessage } from "node:http"; +import { deepStrictEqual } from "node:assert/strict"; +import { once } from "node:events"; + +/** How the release of a source fails (see `source()`) */ +type IRelease = "sync" | "tick" | "none"; + +/** + * A source whose release fails: its own `destroy()` destroys it with an + * error of its own (`sync`), or its `_destroy()` fails (`tick`, on the next + * tick), or neither (`none`) + */ +function source(release: IRelease, failure: Error): Readable { + return new (class extends Readable { + public override destroy(error?: Error): this { + return super.destroy(release === "sync" ? failure : error); + } + })({ + read(): void { + // Pushed by the test + }, + destroy(error, callback): void { + callback(release === "tick" ? failure : error); + }, + }); +} + +suite("A send whose response closes before the send has settled", () => { + for (const release of ["sync", "tick", "none"] as const) { + for (const complete of [false, true]) { + test(`${complete ? "once its body is complete for the client (its Content-Length)" : "before its body is complete"}, of a source whose release ${release === "none" ? "succeeds" : release === "sync" ? "fails at once (its own destroy())" : "fails on the next tick (its _destroy())"}: ${complete ? "resolves" : "rejects as a premature close"}, then releases the source${release === "none" ? "" : ", whose failure is logged"}`, async () => { + const captured = capture(); + const failure = new Error(`The release has failed (${release})`); + const outcome = Promise.withResolvers(); + let released: Readable | null = null; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const data = source(release, failure); + released = data; + if (complete) { + context.response.setHeader("Content-Length", 1); + } + // Commits the headers: a body that waits for more (its end) + data.push("x"); + outcome.resolve( + await context.send(data).then( + () => "resolved", + (error: unknown) => (error as { code?: unknown }).code, + ), + ); + }); + const listening = await listen(app); + try { + const socket = connect(listening.port, HOST); + socket.on("error", () => { + // Destroyed below + }); + await within(once(socket, "connect")); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + let received = ""; + socket.setEncoding("latin1"); + await within( + new Promise((resolve) => { + socket.on("data", (chunk: string) => { + received += chunk; + // Its chunk (with its framing, or its byte) + if (/\r\n\r\n(?:1\r\n)?x/u.test(received)) { + resolve(); + } + }); + }), + ); + socket.destroy(); + deepStrictEqual( + await within(outcome.promise), + complete ? "resolved" : "ERR_STREAM_PREMATURE_CLOSE", + ); + if (release === "none") { + await ioTurn(); + deepStrictEqual( + captured.records.filter(({ msg }) => msg === releasedFailure), + [], + ); + } else { + const record = await captured.waitFor(releasedFailure); + deepStrictEqual( + [record.level, (record.failure as { message?: unknown }).message], + [20, failure.message], + ); + } + deepStrictEqual((released as Readable | null)?.destroyed, true); + } finally { + await listening.close(); + } + }); + } + } + + test("started once its response is destroyed, before the response emits its close: rejects as a premature close, as `done` has settled", async () => { + const wire = new Duplex({ + read(): void { + // The client sends nothing more + }, + write(_chunk: Buffer, _encoding, done): void { + done(); + }, + }); + const request = new IncomingMessage(wire as unknown as Socket); + Object.assign(request, { + method: "GET", + headers: {}, + httpVersionMajor: 1, + httpVersionMinor: 1, + }); + const response = new BindenResponse(request); + response.assignSocket(wire as unknown as Socket); + const data = new PassThrough(); + // In a turn of its own: the close of the response (on the next tick) + // comes before the reactions to the promises of the send + const outcome = await within( + new Promise((resolve) => { + setImmediate(() => { + response.destroy(); + response.send(data).then( + () => { + resolve("resolved"); + }, + (error: unknown) => { + resolve((error as { code?: unknown }).code); + }, + ); + }); + }), + ); + deepStrictEqual( + [outcome, response.closed, data.destroyed], + ["ERR_STREAM_PREMATURE_CLOSE", true, true], + ); + }); +}); diff --git a/packages/binden/test/committed-failures.test.ts b/packages/binden/test/committed-failures.test.ts new file mode 100644 index 00000000..0d2a7cb4 --- /dev/null +++ b/packages/binden/test/committed-failures.test.ts @@ -0,0 +1,215 @@ +import { Binden, type Context } from "../index.js"; +import { PassThrough, Readable } from "node:stream"; +import { capture, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { connect } from "node:net"; +import { once } from "node:events"; + +/** + * Writes raw requests on one connection: what it receives until the server + * closes it, or until it has received what passes `expected` (then whether + * the connection is still open, after a turn of the pending I/O, and once its + * end has been read, after its `close` itself). Never a silence, which a + * stall of the process would make look longer than it is + */ +async function exchange( + port: number, + data: string, + expected: (received: string) => boolean = (): boolean => false, +): Promise<{ received: string; closed: boolean }> { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + const state = { received: "", closed: false }; + const { promise: closing, resolve: close } = Promise.withResolvers(); + socket + .setEncoding("latin1") + .on("data", (chunk: string) => { + state.received += chunk; + }) + .on("error", () => { + // Closed by the server: `close` follows + }) + .on("close", () => { + state.closed = true; + close(null); + }); + socket.write(data); + try { + // Else what has come within 10 s, which the assertions show + await pollUntil(() => state.closed || expected(state.received)).catch( + () => null, + ); + await ioTurn(); + if (socket.readableEnded) { + // Its `close` follows + await within(closing); + } + } finally { + socket.destroy(); + } + return { + received: state.received.replace( + /^(?:Date|Keep-Alive): [^\r]*\r\n/gimu, + "", + ), + closed: state.closed, + }; +} + +/** How a send has settled: `resolved`, or the code (or the message) of its error */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => { + const { code, message } = error as { code?: unknown; message: string }; + return code ?? message; + }, + ); +} + +/** A source that has been destroyed (and has closed) before its send */ +async function destroyed(): Promise { + const source = new Readable({ + read(): void { + // Nothing to read + }, + }); + source.destroy(); + await once(source, "close"); + return source; +} + +/** The next request on the connection, which must never be read as a body */ +const next = "GET /next HTTP/1.1\r\nHost: localhost\r\n\r\n"; + +/** The head that `flushHeaders()` commits: it promises 3 bytes */ +const head = + "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: keep-alive\r\n\r\n"; + +suite( + "A send of a Readable that fails once the headers are committed (by `flushHeaders()`), before any byte of its body", + () => { + for (const [what, sourceOf, error] of [ + [ + "a first chunk longer than its `Content-Length`", + (): Readable => new PassThrough().end("0123456789"), + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + [ + "an empty body (shorter than its `Content-Length`)", + (): Readable => Readable.from([]), + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + [ + "a source that fails before its first chunk", + (): Readable => + new Readable({ + read(): void { + this.destroy(new Error("Failed")); + }, + }), + "Failed", + ], + [ + "a source destroyed with an error that its `_destroy()` swallows (it emits its `close` only)", + (): Readable => + new Readable({ + read(): void { + this.destroy(new Error("Swallowed")); + }, + destroy(_error, callback): void { + callback(null); + }, + }), + "Swallowed", + ], + [ + "a source destroyed before the send", + destroyed, + "ERR_STREAM_PREMATURE_CLOSE", + ], + [ + "the request of the response (an echo), longer than its `Content-Length`", + null, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + ] as const) { + test(`${what}: the send rejects, and the response is closed (the next request of the connection is not read as its body)`, async () => { + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + const { response } = context; + response.setHeader("Content-Length", 3); + response.flushHeaders(); + const source = + sourceOf === null ? context.request : await sourceOf(); + const failure = await outcomeOf(context.send(source)); + outcome.resolve([failure, response.destroyed]); + // A work that catches the failure, and ends the response + response.end(); + }, + ); + const listening = await listen(app); + try { + const { received, closed } = await exchange( + listening.port, + sourceOf === null + ? `POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\n0123456789${next}` + : `GET / HTTP/1.1\r\nHost: localhost\r\n\r\n${next}`, + ); + deepStrictEqual( + [await within(outcome.promise), received, closed], + [[error, true], head, true], + ); + } finally { + await listening.close(); + } + }); + } + + test("before the headers are committed, a source destroyed before the send rejects, and its error is replied (500)", async () => { + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + const { response } = context; + response.setHeader("Content-Length", 3); + const failure = await outcomeOf(context.send(await destroyed())); + outcome.resolve([failure, response.started, response.destroyed]); + throw new Error("Replied"); + }, + ); + const listening = await listen(app); + try { + const { received, closed } = await exchange( + listening.port, + `GET / HTTP/1.1\r\nHost: localhost\r\n\r\n${next}`, + // The next response, complete + (data) => data.endsWith("\r\n\r\nnext"), + ); + deepStrictEqual(await within(outcome.promise), [ + "ERR_STREAM_PREMATURE_CLOSE", + false, + false, + ]); + // The 500, then the next response on the same connection + ok( + received.startsWith("HTTP/1.1 500 Internal Server Error\r\n"), + received, + ); + ok(received.endsWith("\r\n\r\nnext"), received); + ok(!closed); + } finally { + await listening.close(); + } + }); + }, +); diff --git a/packages/binden/test/connection.ts b/packages/binden/test/connection.ts new file mode 100644 index 00000000..b8ab5c8c --- /dev/null +++ b/packages/binden/test/connection.ts @@ -0,0 +1,77 @@ +import { connect } from "node:net"; +import { once } from "node:events"; + +/** What a client has received on its connection, and whether it has closed */ +export interface IConversation { + readonly received: string; + readonly closed: boolean; +} + +/** How a conversation is read (see `converse()`) */ +export interface IConverseOptions { + /** Whether what has been received is complete (the conversation ends then) */ + readonly complete?: ((received: string) => boolean) | undefined; + /** Nothing is read until it resolves (e.g. a reply that must not finish before) */ + readonly hold?: Promise | undefined; + /** The deadline of the conversation (a hang must fail, not block the runner) */ + readonly ms?: number | undefined; +} + +/** + * Writes `text` (e.g. pipelined requests) on a new connection to the port of + * `127.0.0.1`, then reads (`latin1`) until what has been received is + * `complete`, or the connection closes: never judged by silence. Rejects + * once `ms` (10 s by default) have passed. + */ +export async function converse( + port: number, + text: string, + { complete = (): boolean => false, hold, ms = 10_000 }: IConverseOptions = {}, +): Promise { + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let received = ""; + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + reject( + new Error(`No end within ${String(ms)} ms: ${received.slice(-200)}`), + ); + }, ms); + socket + .setEncoding("latin1") + .on("data", (chunk: string) => { + received += chunk; + if (complete(received)) { + resolve(false); + } + }) + .on("error", () => { + // Its close follows + }) + .on("close", () => { + resolve(true); + }); + if (typeof hold !== "undefined") { + socket.pause(); + const resume = (): void => { + socket.resume(); + }; + hold.then(resume, resume); + } + socket.write(text); + try { + const closed = await promise; + return { received, closed }; + } finally { + clearTimeout(timer); + } + } finally { + socket.destroy(); + } +} + +/** A `GET` of the path on a connection that may be kept alive */ +export function get(path: string): string { + return `GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`; +} diff --git a/packages/binden/test/context-helpers.test.ts b/packages/binden/test/context-helpers.test.ts new file mode 100644 index 00000000..a1ffc46b --- /dev/null +++ b/packages/binden/test/context-helpers.test.ts @@ -0,0 +1,81 @@ +import { Binden, type Context } from "../index.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { listen, send } from "./helpers.js"; +import { suite, test } from "node:test"; + +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(); + } + }); +}); diff --git a/test/context.test.ts b/packages/binden/test/context.test.ts similarity index 90% rename from test/context.test.ts rename to packages/binden/test/context.test.ts index a314622a..98e56359 100644 --- a/test/context.test.ts +++ b/packages/binden/test/context.test.ts @@ -1,28 +1,37 @@ /* eslint-disable init-declarations, @typescript-eslint/no-loop-func */ -import { ok, deepStrictEqual } from "node:assert"; -import { Server, createServer } from "node:http"; -import fastJSON from "fast-json-stringify"; -import fetch from "node-fetch"; -import sinon from "sinon"; - import { - Context, BindenError, BindenRequest, BindenResponse, + Context, } from "../index.js"; +import { Server, createServer } from "node:http"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert"; +import type { AddressInfo } from "node:net"; +import fastJSON from "fast-json-stringify"; +import fetch from "node-fetch"; +import sinon from "sinon"; +import { toError } from "./helpers.js"; + +/** The port of a listening server */ +function portOf(server: { address(): unknown }): number { + return (server.address() as AddressInfo).port; +} -const port = 8080; -const url = `http://localhost:${port}`; +let url = ""; suite("Context", () => { let server: Server; - setup((done) => { + beforeEach((_t, done) => { server = createServer({ IncomingMessage: BindenRequest, ServerResponse: BindenResponse, - }).listen(port, done); + }).listen(0, "127.0.0.1", () => { + url = `http://127.0.0.1:${portOf(server)}`; + done(); + }); }); test("constructor", async () => { @@ -43,7 +52,7 @@ suite("Context", () => { context.done = false; ok(context.done); } catch (error) { - reject(error); + reject(toError(error)); } finally { response.end(resolve); } @@ -69,7 +78,7 @@ suite("Context", () => { deepStrictEqual(context.setHeader(name, value), context); mock.verify(); } catch (error) { - reject(error); + reject(toError(error)); } finally { response.end(resolve); } @@ -95,7 +104,7 @@ suite("Context", () => { deepStrictEqual(context.status(status), context); mock.verify(); } catch (error) { - reject(error); + reject(toError(error)); } finally { response.end(resolve); } @@ -158,7 +167,7 @@ suite("Context", () => { .finally(() => context.response.end(resolve)); } catch (error) { context.response.end(() => { - reject(error); + reject(toError(error)); }); } }); @@ -227,7 +236,7 @@ suite("Context", () => { deepStrictEqual(error.json, json); deepStrictEqual(error.status, status); } catch (err) { - reject(err); + reject(toError(err)); } } finally { response.end(resolve); @@ -238,5 +247,5 @@ suite("Context", () => { await serverPromise; }); - teardown((done) => server.close(done)); + afterEach((_t, done) => server.close(done)); }); diff --git a/packages/binden/test/created-errors.test.ts b/packages/binden/test/created-errors.test.ts new file mode 100644 index 00000000..d92d0910 --- /dev/null +++ b/packages/binden/test/created-errors.test.ts @@ -0,0 +1,593 @@ +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { suite, test } from "node:test"; +import { BindenError } from "../index.js"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** The directory of the packages of the monorepo */ +const packages = join(import.meta.dirname, "..", ".."); + +/** Whitespace and comments, which may come between a name and its `(` */ +const gap = String.raw`(?:\s|/\*(?:[^*]|\*(?!/))*\*/|//[^\n]*)*`; + +/** A name that ends with `Error` or `Exception` (e.g. `DOMException`) */ +const errorName = String.raw`(?:[A-Z][\w$]*)?(?:Error|Exception)`; + +/** + * The qualifiers of a name, maybe none (e.g. `globalThis.`), also optional + * ones (e.g. `globalThis?.`) + */ +const qualifiers = String.raw`(?:[\w$]+\s*\??\.\s*)*`; + +/** + * The opening of a call after a name (`(`), also of an optional call (e.g. + * `?.(`) + */ +const call = String.raw`${gap}(?:\?\.${gap})?\(`; + +/** + * The creations of an error that the scan finds, each with what it creates + * (`null`: the name that it has matched): the constructions and calls of a + * name that ends with `Error` or `Exception`, every class that extends one + * and whose name does not end so (whose constructions it would not see), and + * the calls that create an error or capture the frames of the caller. + * + * A static scan cannot see every creation: a constructor or a method that + * it does not find by its name as it is called (e.g. passed as an argument, + * returned by a call, an element of an array or a property of another name, + * a part of an expression larger than the whole value of an assignment, e.g. + * a conditional, a name in parentheses, a computed member, or the tag of a + * template), the last of a sequence that a default export gives (e.g. + * `export default (0, TypeError)`) in parentheses of its own (`(0, + * (TypeError))`), in a nested sequence (`(0, (0, TypeError))`), with any + * syntax of TypeScript around it (an `as`, a `!`, a `satisfies` or a type + * assertion, e.g. `(0, TypeError as typeof Error)`, `(0, TypeError!)` or + * `(0, TypeError)`), or after an earlier one that has a + * `;`, a parenthesis in a literal (a string, a template or a regular + * expression, e.g. `")"`, `"("` or `/\)/`), braces (an object, a block or + * the `${}` of a template) or parentheses nested in parentheses of + * its own (e.g. `"a;b"`, `{}` or `f(g(1))`, while one level of them, e.g. + * `f(1)` or `[(1)]`, is found), a class that extends an expression + * (e.g. a mixin), an `abort()` whose reason is `undefined` only at run time + * (e.g. a variable), and the errors that V8 or Node.js create in the methods + * that binden calls (e.g. the `TypeError` of a wrong argument). The check of + * those is at run time: `fixtures/created-error-retention.ts`, for the + * refusals of binden. A creation quoted in a comment or a string may be + * found too, and the member of a sequence on another line (e.g. `.name` on + * the line after `export default (0, TypeError)`), and a constructor type + * of TypeScript whose parameters have parentheses of their own (e.g. `new + * (callback: () => void) => unknown`, while `new (...args: never[]) => + * unknown` is not taken for a class expression): the scan errs towards a + * finding. + */ +const creations: readonly (readonly [RegExp, string | null])[] = [ + // A construction, with or without `new` (a built-in constructor may be + // called, also optionally, e.g. `TypeError?.()`, by `call()` or `apply()`, + // or once bound by `bind()`), maybe qualified (e.g. `globalThis.TypeError`) + [ + new RegExp( + String.raw`(?:\bnew\s+)?(?${errorName})(?:\s*\??\.\s*(?:call|apply|bind))?${call}`, + "gu", + ), + null, + ], + // A construction without arguments (e.g. `new TypeError;`) + [ + new RegExp( + String.raw`\bnew\s+${qualifiers}(?${errorName})(?![\w$])(?!${gap}\()`, + "gu", + ), + null, + ], + // Of a class expression (e.g. `new (class extends Error {})()` or + // `new class extends Error {}()`), but a constructor type of TypeScript + // (e.g. `abstract new (...args: never[]) => unknown`), whose parameters + // have no parentheses of their own + [/\bnew\s*\((?![^()]*\)\s*=>)/gu, "class expression"], + [/\bnew\s+class\b/gu, "class expression"], + // A class of an error whose name does not end with `Error` or `Exception` + // (but a class expression constructed at once, found above) + [ + new RegExp( + String.raw`(?)?\s*)=${gap}${qualifiers}(?${errorName})(?![\w$])(?!${gap}(?:\??\.|\[|\())`, + "gu", + ), + null, + ], + // Or destructured, also by an assignment without a declaration (but a + // type that an arrow function returns) + [ + new RegExp( + String.raw`\{[^}]*?(?${errorName})(?![\w$])[^}]*\}\s*=(?!>)`, + "gu", + ), + null, + ], + // Or exported without its name, whose constructions (e.g. by a default + // import, of any name) are not seen: as the default export, also in + // parentheses (but a construction or a member of it, found above, and the + // first of a sequence), or as the last of a sequence in parentheses (e.g. + // `export default (0, TypeError)`, but the forms of the sequences that + // `creations` names), or renamed to a name that does not end + // with `Error` or `Exception` by a list of exports or imports, each rename + // of a list (e.g. `export { TypeError as Refusal }`, `TypeError as + // "Refusal"` or `TypeError as default`), also with comments around `as` + [ + new RegExp( + String.raw`\bexport\s+default(?:${gap}\()*${gap}${qualifiers}(?${errorName})(?![\w$])(?!${gap}(?:\??\.|\[|\(|,))`, + "gu", + ), + null, + ], + [ + new RegExp( + String.raw`\bexport\s+default(?:${gap}\()+(?:(?:[^;(){}]|\([^;(){}]*\))*?,)+${gap}${qualifiers}(?${errorName})(?![\w$])${gap}\)`, + "gu", + ), + null, + ], + [ + new RegExp( + String.raw`(?<=\{[^{}]*)(?${errorName})(?![\w$])${gap}as(?![\w$])${gap}(?!(?:[\w$]*|"[^"]*|'[^']*)(?:Error|Exception)["']?(?![\w$]))(?:[\w$]+|"[^"]*"|'[^']*')(?=[^{}]*\})`, + "gu", + ), + null, + ], +]; + +/** + * The creations that the scan finds in the sources and that are allowed, as + * `file name`: the probe of the engine in `exchange.ts`, whose frames + * (captured once, when the module is loaded) are of no request + */ +const allowed: readonly string[] = [ + "binden/src/exchange.ts captureStackTrace()", +]; + +/** + * The creations of errors (see `creations`) of a source that are not + * formatted when they are created, as `file:line name`, in their order: each + * one is passed to `formatStack()` at once, but a `BindenError`, which + * formats itself + */ +function unformatted(file: string, source: string): string[] { + const found: [number, string][] = []; + for (const [pattern, label] of creations) { + for (const { index, groups } of source.matchAll(pattern)) { + const name = label ?? groups?.name ?? ""; + const before = source.slice(0, index); + if (name !== "BindenError" && !/\bformatStack\(\s*$/u.test(before)) { + const line = before.split("\n").length; + found.push([index, `${file}:${line} ${name}`]); + } + } + } + return found.toSorted(([a], [b]) => a - b).map(([, entry]) => entry); +} + +/** An entry of `unformatted()` without its line: `file name` */ +function withoutLine(entry: string): string { + return entry.replace(/:\d+ /u, " "); +} + +/** + * The sources of every package (its `index.ts`, and every file of its + * `src/`), by their paths relative to `packages/` + */ +function sources(): Map { + const all = new Map(); + for (const name of readdirSync(packages)) { + const src = join(packages, name, "src"); + const files = [ + "index.ts", + // None of @binden/logger, whose code is its `index.ts` + ...(existsSync(src) ? readdirSync(src, { recursive: true }) : []) + .map(String) + .filter((file) => file.endsWith(".ts")) + .map((file) => join("src", file)), + ]; + for (const file of files) { + // With `/` on every platform (Windows joins with `\`) + const path = join(name, file).replaceAll("\\", "/"); + all.set(path, readFileSync(join(packages, path), "utf8")); + } + } + return all; +} + +/** + * Runs `run` while `Error.prepareStackTrace` records each error whose stack + * V8 formats (once it is first read), and returns them with what `run` + * returns (also what it throws) + */ +function recording(run: () => unknown): [unknown, WeakSet] { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + formatted.add(error); + return [ + `${error.name}: ${error.message}`, + ...frames.map((frame) => ` at ${String(frame)}`), + ].join("\n"); + }, + ); + try { + return [run(), formatted]; + } catch (error: unknown) { + return [error, formatted]; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +suite("The errors that the packages of binden create", () => { + test("are formatted when they are created (a `BindenError`, or by `formatStack()`), as far as a scan of the sources of every package sees: neither a constructor that it does not find by its name nor an error that V8 or Node.js create (checked at run time for the refusals of `fixtures/created-error-retention.ts`)", () => { + const all = sources(); + const found = [...all].flatMap(([file, source]) => + unformatted(file, source), + ); + // Not vacuous: every package creates errors + const created = (name: string): number => + [...all] + .filter(([file]) => file.startsWith(`${name}/`)) + .reduce( + (count, [, source]) => + count + + creations.reduce( + (sum, [pattern]) => sum + [...source.matchAll(pattern)].length, + 0, + ), + 0, + ); + for (const name of [ + "binden", + "body", + "compression", + "logger", + "multipart", + ]) { + ok(created(name) > 0, name); + } + deepStrictEqual( + found.filter((entry) => !allowed.includes(withoutLine(entry))), + [], + ); + // Each one allowed is still there, once + deepStrictEqual( + found.map(withoutLine).filter((entry) => allowed.includes(entry)), + allowed, + ); + }); + + test("the scan finds every form of a creation of an error that it knows, unless the error is formatted at once", () => { + const source = [ + 'throw new TypeError("not formatted");', + 'throw formatStack(new RangeError("formatted"));', + "throw new BindenError(400);", + "throw formatStack(", + ' new Error("formatted"),', + ");", + 'throw Object.assign(new Error("not formatted"), {});', + 'callback(new AggregateError([], "not formatted"));', + // Without `new` + 'throw TypeError("not formatted");', + 'throw formatStack(URIError("formatted"));', + 'throw new DOMException("not formatted", "AbortError");', + // Qualified + 'throw new globalThis.TypeError("not formatted");', + 'throw formatStack(new globalThis.SyntaxError("formatted"));', + 'throw Reflect.construct(RangeError, ["not formatted"]);', + 'throw formatStack(Reflect.construct(RangeError, ["formatted"]));', + "controller.abort();", + 'controller.abort(formatStack(new BindenAbortError("deadline")));', + "// See `Exchange.abort()`", + // Another function whose name ends with `formatStack` + 'throw unformatStack(new Error("not formatted"));', + 'throw new (class Own extends Error {})("not formatted");', + "const Constructor = TypeError;", + 'throw new Constructor("not seen");', + // A class whose constructions the scan would not see + "class Failure extends Error {}", + "export class Refusal extends globalThis.TypeError {}", + "const Anonymous = class extends Error {};", + "class TeapotError extends BindenError {}", + 'throw new class extends Error {}("not formatted");', + // Without arguments, or with a comment before them + "throw new TypeError;", + "throw formatStack(new RangeError);", + 'throw new Error /* why */ ("not formatted");', + 'throw formatStack(new Error /* why */ ("formatted"));', + "throw TypeError // why", + ' ("not formatted");', + 'throw Error.call(undefined, "not formatted");', + 'throw formatStack(RangeError.apply(undefined, ["formatted"]));', + "Error.captureStackTrace(target);", + "// See `Error.captureStackTrace()`", + "controller.abort(undefined);", + "controller.abort(reason);", + "const signal = AbortSignal.timeout(1000);", + "const { TypeError: Destructured } = globalThis;", + // Optional calls and qualifiers, and a bound constructor + 'throw TypeError?.("not formatted");', + 'throw formatStack(RangeError?.("formatted"));', + 'throw globalThis?.TypeError("not formatted");', + 'throw Error?.call?.(undefined, "not formatted");', + 'throw Error.bind(null)("not formatted");', + 'throw Reflect?.construct?.(RangeError, ["not formatted"]);', + "controller?.abort?.();", + "const deadline = AbortSignal?.timeout?.(1000);", + "Error.captureStackTrace?.(target);", + // An abort whose reason is `undefined` + "controller.abort(void 0);", + "controller.abort(undefined, reason);", + // A constructor kept by an assignment without a declaration, a logical + // one, a default or a destructuring + "Assigned = TypeError;", + "this.#constructor ??= globalThis.RangeError;", + "function construct(Constructor = TypeError) {}", + "({ TypeError: Assigned } = globalThis);", + // Exported without its name: as the default export, or renamed + "export default TypeError;", + "export default globalThis.RangeError;", + "export { TypeError as Refusal };", + 'import { BindenAbortError as Abort } from "binden";', + // Each rename of a list (also on a line of its own, and to a string + // or `default`), and a default export in parentheses + "export {", + " TypeError as Refusal,", + " RangeError as Other,", + "};", + 'import { formatStack, AbortError as Abort, URIError as Failure } from "x";', + 'export { TypeError as "Refusal", RangeError as default };', + "export default (TypeError);", + "export default ( /* why */ globalThis.RangeError );", + // With comments around `as`, and the last of a sequence + "export { TypeError /* why */ as Refusal };", + "import { RangeError as // why", + ' Other } from "x";', + "export default (0, TypeError);", + "export default ((0, /* why */ globalThis.RangeError));", + "export default (void 0, f(1), URIError);", + // None creates an error + "reportError(error);", + "const failed = error instanceof TypeError;", + "class Cookies extends Set {}", + "const { status, headers } = response;", + "type Alias = TypeError;", + "type Generic = TypeError | T;", + "const same = error.constructor === TypeError;", + "const other = error.constructor != TypeError;", + "const read = (): { error: TypeError } => value;", + "const { name } = TypeError;", + "const kind = TypeError.name;", + "const codes = ErrorCodes;", + "controller.abort(voided);", + "export default class Refusal {}", + "export { TypeError as OwnError };", + 'export { RangeError as OwnError, TypeError as "OtherError" };', + "export default (TypeError.name);", + "const cast = { error: value as TypeError };", + 'export { Refused, type IRefusal } from "./refusal.js";', + 'export default formatStack(new TypeError("formatted"));', + "export default TypeError.name;", + "export default (TypeError, 0);", + "export default (0, TypeError.name);", + "export { TypeError /* as Refusal */ };", + "export { TypeError as /* why */ OwnError };", + "type IConstructor = abstract new (...args: never[]) => unknown;", + "const made: new () => object = Object;", + // A constructor type whose parameters have parentheses of their own + "type ICallback = new (callback: () => void) => unknown;", + ].join("\n"); + deepStrictEqual(unformatted("source.ts", source), [ + "source.ts:1 TypeError", + "source.ts:7 Error", + "source.ts:8 AggregateError", + "source.ts:9 TypeError", + "source.ts:11 DOMException", + "source.ts:12 TypeError", + "source.ts:14 Reflect.construct", + "source.ts:16 abort()", + "source.ts:19 Error", + "source.ts:20 class expression", + "source.ts:21 TypeError", + "source.ts:23 subclass", + "source.ts:24 subclass", + "source.ts:25 subclass", + "source.ts:27 class expression", + "source.ts:28 TypeError", + "source.ts:30 Error", + "source.ts:32 TypeError", + "source.ts:34 Error", + "source.ts:36 captureStackTrace()", + "source.ts:38 abort()", + "source.ts:40 AbortSignal.timeout()", + "source.ts:41 TypeError", + "source.ts:42 TypeError", + "source.ts:44 TypeError", + "source.ts:45 Error", + "source.ts:46 Error", + "source.ts:47 Reflect.construct", + "source.ts:48 abort()", + "source.ts:49 AbortSignal.timeout()", + "source.ts:50 captureStackTrace()", + "source.ts:51 abort()", + "source.ts:52 abort()", + "source.ts:53 TypeError", + "source.ts:54 RangeError", + "source.ts:55 TypeError", + "source.ts:56 TypeError", + "source.ts:57 TypeError", + "source.ts:58 RangeError", + "source.ts:59 TypeError", + "source.ts:60 BindenAbortError", + "source.ts:62 TypeError", + "source.ts:63 RangeError", + "source.ts:65 AbortError", + "source.ts:65 URIError", + "source.ts:66 TypeError", + "source.ts:66 RangeError", + "source.ts:67 TypeError", + "source.ts:68 RangeError", + "source.ts:69 TypeError", + "source.ts:70 RangeError", + "source.ts:72 TypeError", + "source.ts:73 RangeError", + "source.ts:74 URIError", + "source.ts:102 class expression", + ]); + }); +}); + +suite("A `BindenError`", () => { + test("formats its stack in its constructor, also when the application creates it", () => { + const [error, formatted] = recording(() => new BindenError(400)); + ok(error instanceof BindenError); + ok(formatted.has(error)); + const { stack = "" } = error; + ok(stack.startsWith("BindenError: Bad Request\n"), stack); + }); + + test("of a subclass is not formatted in the constructor of `BindenError`: its stack keeps the header that its own constructor gives it", () => { + class Teapot extends BindenError { + public constructor() { + super(418, { message: "Short and stout" }); + this.name = "Teapot"; + } + } + const [error, formatted] = recording(() => new Teapot()); + ok(error instanceof Teapot); + strictEqual(formatted.has(error), false); + const { stack = "" } = error; + ok(stack.startsWith("Teapot: Short and stout\n"), stack); + }); + + for (const [what, create, type] of [ + ["status", (): BindenError => new BindenError(99), RangeError], + [ + "header", + (): BindenError => + new BindenError(400, { headers: { "Content-Length": "1" } }), + TypeError, + ], + ] as const) { + test(`with an invalid ${what} throws an error that is formatted`, () => { + const [error, formatted] = recording(create); + strictEqual((error as Error).constructor, type); + ok(formatted.has(error as Error)); + }); + } +}); + +/** + * Runs the fixture of the errors that binden creates in a process of its + * own, whose collections the test controls (`--expose-gc`): whether the + * requests and the responses are collected while the application keeps the + * errors + */ +function retained(mode: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "created-error-retention.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite( + "An error that binden creates, kept by the application without its stack read, keeps neither the request nor the response", + () => { + for (const [mode, how, kept] of [ + ["argument", "the `TypeError` of `response.status(99)`", "TypeError"], + [ + "dotfile", + "the `403` of `sendFile()` for a dotfile that it denies", + "BindenError 403", + ], + [ + "hidden", + "the `404` of `sendFile()` for a dotfile that it ignores", + "BindenError 404", + ], + [ + "outside", + "the `404` of `sendFile()` for a path that leaves its root", + "BindenError 404", + ], + [ + "url", + "the `400` of `context.url` for an invalid `Host`", + "BindenError 400", + ], + ["throw", "the `BindenError` of `context.throw()`", "BindenError 418"], + [ + "redirect", + "the `TypeError` of `response.redirect()` for a location that is not a URL", + "TypeError", + ], + [ + "json", + "the `TypeError` of `JSON.stringify()` that `response.json()` rejects with", + "TypeError", + ], + ] as const) { + test(how, () => { + // 60 requests, whose errors are kept + deepStrictEqual(retained(mode), { + status: 0, + stdout: `collected 60 ${kept}\n`, + stderr: "", + }); + }); + } + }, +); diff --git a/packages/binden/test/cut.test.ts b/packages/binden/test/cut.test.ts new file mode 100644 index 00000000..1fb9a67f --- /dev/null +++ b/packages/binden/test/cut.test.ts @@ -0,0 +1,1396 @@ +import { type AddressInfo, Socket, connect } from "node:net"; +import { + Binden, + BindenResponse, + type Context, + framingLength, +} from "../index.js"; +import { + IncomingMessage, + OutgoingMessage, + type Server, + ServerResponse, + createServer, +} from "node:http"; +import { capture, ioTurn, listen, never, sleep, within } from "./helpers.js"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { kFramework } from "../src/seal.js"; +import { once } from "node:events"; + +/** What a client has read of a connection until the server closed it (or until it was idle) */ +interface IConversation { + readonly data: string; + readonly closed: boolean; +} + +/** + * Writes requests on one connection, and reads every byte until the server + * closes it, or until nothing comes for `idle` milliseconds (judged after a + * turn of the pending I/O, see `ioTurn()`: after a stall of the process, its + * timer runs before the data, or the close, that has come meanwhile is seen; + * once the end of the connection has been read, its `close` itself decides) + */ +async function converse( + port: number, + requests: string, + idle = 300, +): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + const { promise: quiet, resolve } = Promise.withResolvers(); + const expire = (): void => { + const seen = data.length; + const judge = (): void => { + // Else the data that has come re-armed the timer, or the end of the + // connection has been read: its `close`, which follows, decides + if (data.length === seen && !socket.readableEnded) { + resolve(false); + } + }; + ioTurn().then(judge, judge); + }; + let timer = setTimeout(expire, idle); + const arm = (): void => { + clearTimeout(timer); + timer = setTimeout(expire, idle); + }; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + arm(); + }); + socket.on("error", () => { + // E.g. reset by the server: what it has sent is read + }); + const closed = once(socket, "close").then(() => true); + socket.write(requests); + try { + const ended = await within(Promise.race([closed, quiet])); + return { data: data.replaceAll(/\r\nDate: [^\r]*/gu, ""), closed: ended }; + } finally { + clearTimeout(timer); + socket.destroy(); + } +} + +/** The body of a chunked body (and its last chunk), as Node.js has chunked it */ +function dechunk(chunked: string): string { + let body = ""; + let rest = chunked; + for (;;) { + const line = rest.indexOf("\r\n"); + const size = Number.parseInt(rest.slice(0, line), 16); + ok(line > 0 && Number.isSafeInteger(size), `Not chunked: ${chunked}`); + if (size === 0) { + deepStrictEqual(rest, "0\r\n\r\n"); + return body; + } + body += rest.slice(line + 2, line + 2 + size); + rest = rest.slice(line + 2 + size + 2); + } +} + +/** A server listening on a free port, closed by `close()` */ +async function serve(server: Server): Promise<{ + port: number; + close(): Promise; +}> { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + port: (server.address() as AddressInfo).port, + close: async (): Promise => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }, + }; +} + +/** A response (with an injected one after it) of a UTF-8 body framed by its length in characters */ +const injected = "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nINJECTED"; + +/** + * Writes that Node.js refuses for `strictContentLength` once it has committed + * the head (a `Content-Length` of 2, unless the case sets its own) + */ +const mismatches: Record void> = { + "end() of a longer body": (response) => { + response.end("abc"); + }, + "end() of a shorter body": (response) => { + response.end("a"); + }, + "end() of a longer body in hex": (response) => { + response.end("616263", "hex"); + }, + "write() of a shorter body, then end()": (response) => { + response.write("a"); + response.end(); + }, + "write() of the body, then end() of more": (response) => { + response.write("ab"); + response.end(injected); + }, + "a second write() past the length": (response) => { + response.write("ab"); + response.write("c"); + }, + "end() of a UTF-8 body framed by its length in characters": (response) => { + // Every `€` is two bytes more than a character: the excess is the + // injected response + const body = `${"€".repeat(injected.length / 2)}${injected}`; + response.setHeader("Content-Length", body.length); + response.end(body); + }, +}; + +const pipelined = + "GET /mismatch HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"; + +/** Runs a mismatch on a strict response of `Content-Length: 2` */ +function mismatch(name: string, response: ServerResponse): void { + response.strictContentLength = true; + response.setHeader("Content-Type", "text/plain"); + response.setHeader("Content-Length", 2); + mismatches[name](response); +} + +/** A server of Node.js whose handler destroys the response that throws */ +function native(name: string): Server { + return createServer((request: IncomingMessage, response: ServerResponse) => { + if (request.url === "/next") { + response.end("next"); + return; + } + try { + mismatch(name, response); + } catch { + response.destroy(); + } + }); +} + +/** An application whose middleware lets the error out (also through the access log) */ +function application( + name: string, + outcomes: unknown[], +): { app: Binden; captured: ReturnType } { + const captured = capture(); + const app = new Binden({ logger: captured.logger, access_log: true }).use( + async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + context.onCleanup(() => { + outcomes.push(context.signal.aborted, response.writableFinished); + }); + try { + mismatch(name, response); + } catch (error: unknown) { + // Destroyed before the error is thrown + outcomes.push((error as { code?: unknown }).code, response.destroyed); + throw error; + } + }, + ); + return { app, captured }; +} + +suite("A write() or an end() that throws once the head is committed", () => { + for (const name of Object.keys(mismatches)) { + test(`sends nothing more and closes the connection: ${name}`, async () => { + const outcomes: unknown[] = []; + const { app, captured } = application(name, outcomes); + const servers = [native(name), app.createServer()]; + const conversations: IConversation[] = []; + for (const server of servers) { + const listening = await serve(server); + try { + conversations.push(await converse(listening.port, pipelined)); + } finally { + await listening.close(); + } + } + const [natively, binden] = conversations; + // Nothing (neither the head, nor the chunk, nor an injected response), + // as Node.js itself: also not by the cork that Node.js releases on the + // next tick + deepStrictEqual(binden, natively); + deepStrictEqual(binden, { data: "", closed: true }); + deepStrictEqual(outcomes, [ + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + true, + true, + false, + ]); + const { status, aborted } = await captured.waitFor( + "The request has completed", + ); + deepStrictEqual([status, aborted], [200, true]); + }); + } + + test("by fetch(): no response, as from Node.js", async () => { + const name = "end() of a UTF-8 body framed by its length in characters"; + const { app } = application(name, []); + for (const server of [native(name), app.createServer()]) { + const listening = await serve(server); + try { + await rejects( + fetch(`http://127.0.0.1:${listening.port}/mismatch`, { + signal: AbortSignal.timeout(10_000), + }), + (error: unknown) => + error instanceof TypeError && error.message === "fetch failed", + ); + } finally { + await listening.close(); + } + } + }); + + test("once a write has been flushed: closes the connection after exactly what has been sent", async () => { + const app = new Binden().use(async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + response.strictContentLength = true; + response.setHeader("Content-Length", 5); + response.write("ab"); + await response.flush(); + // Past the length: Node.js refuses it before it writes it + response.write("abcd"); + }); + const listening = await listen(app); + try { + deepStrictEqual(await converse(listening.port, pipelined), { + data: "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\nab", + closed: true, + }); + } finally { + await listening.close(); + } + }); + + test("of a response that waits for the one before it sends nothing of it once its socket comes", async () => { + const outcomes: unknown[] = []; + const app = new Binden().use(async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/first") { + await sleep(50); + await context.text("first"); + return; + } else if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + // Without a socket yet: its head and its chunk are queued + outcomes.push(response.socket); + response.strictContentLength = true; + response.setHeader("Content-Length", 2); + response.end("abc"); + }); + const listening = await listen(app); + try { + deepStrictEqual( + await converse( + listening.port, + "GET /first HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /mismatch HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ), + { + data: "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 5\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\nfirst", + closed: true, + }, + ); + deepStrictEqual(outcomes, [null]); + } finally { + await listening.close(); + } + }); + + test("for a chunk or an encoding refused before anything is written leaves the response to its reply", async () => { + const refused: unknown[] = []; + const app = new Binden().use(({ response }: Context) => { + response.setHeader("Content-Type", "text/plain"); + response.write("part;"); + const calls: (() => unknown)[] = [ + (): unknown => response.write(null), + (): unknown => response.write({}), + (): unknown => response.write("x", "bogus" as BufferEncoding), + (): unknown => response.end({}), + (): unknown => response.end("x", "bogus" as BufferEncoding), + ]; + for (const call of calls) { + try { + call(); + } catch (error: unknown) { + refused.push((error as { code?: unknown }).code); + } + } + response.end("rest"); + }); + const listening = await listen(app); + try { + const { data, closed } = await converse( + listening.port, + "GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const end = data.indexOf("\r\n\r\n"); + const [head, chunks] = [data.slice(0, end), data.slice(end + 4)]; + deepStrictEqual( + [head, dechunk(chunks), closed], + [ + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\nTransfer-Encoding: chunked", + "part;rest", + true, + ], + ); + deepStrictEqual(refused, [ + "ERR_STREAM_NULL_VALUES", + "ERR_INVALID_ARG_TYPE", + "ERR_UNKNOWN_ENCODING", + "ERR_INVALID_ARG_TYPE", + "ERR_UNKNOWN_ENCODING", + ]); + } finally { + await listening.close(); + } + }); + + test("for a body that the response may not have (to HEAD, or with 204), caught: an end() sends the reply complete, as Node.js", async () => { + // `rejectNonStandardBodyWrites`: Node.js generates the head, then throws + // `ERR_HTTP_BODY_NOT_ALLOWED`, having queued nothing more + const thrown: unknown[] = []; + const handler = ( + request: IncomingMessage, + response: ServerResponse, + ): void => { + if (request.url === "/next") { + response.end("next"); + return; + } else if (request.url?.startsWith("/204") === true) { + response.statusCode = 204; + } + try { + if (request.url?.endsWith("end") === true) { + response.end("body"); + } else { + response.write("body"); + } + } catch (error: unknown) { + thrown.push((error as { code?: unknown }).code, response.headersSent); + } + response.end(); + }; + const options = { rejectNonStandardBodyWrites: true }; + const app = new Binden().use(({ request, response }: Context) => { + handler(request, response); + }); + const conversations: IConversation[][] = []; + for (const server of [ + createServer(options, handler), + app.createServer(options), + ]) { + const listening = await serve(server); + const replies: IConversation[] = []; + try { + for (const target of [ + "HEAD /", + "HEAD /end", + "GET /204", + "GET /204end", + ]) { + replies.push( + await converse( + listening.port, + `${target} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ), + ); + } + } finally { + await listening.close(); + } + conversations.push(replies); + } + const [natively, binden] = conversations as [ + IConversation[], + IConversation[], + ]; + deepStrictEqual(binden, natively); + // Complete, and the connection is kept for the next response + for (const [index, { data, closed }] of binden.entries()) { + ok( + data.startsWith( + index < 2 ? "HTTP/1.1 200 OK\r\n" : "HTTP/1.1 204 No Content\r\n", + ) && data.endsWith("\r\nContent-Length: 4\r\n\r\nnext"), + data, + ); + ok(closed); + } + deepStrictEqual( + thrown, + Array.from({ length: 8 }, () => [ + "ERR_HTTP_BODY_NOT_ALLOWED", + true, + ]).flat(), + ); + }); + + test("for a chunk that Node.js refuses before it takes it: the response stays usable", () => { + // Such checks of binden itself come first (see above): they would leave + // the response as it is, also from Node.js once the head is committed + const write = Reflect.get(OutgoingMessage.prototype, "write") as unknown; + try { + for (const code of [ + "ERR_HTTP_BODY_NOT_ALLOWED", + "ERR_INVALID_ARG_TYPE", + "ERR_STREAM_NULL_VALUES", + "ERR_UNKNOWN_ENCODING", + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ]) { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response.flushHeaders(); + Reflect.set(OutgoingMessage.prototype, "write", (): never => { + throw Object.assign(new Error(code), { code }); + }); + throws(() => response.write("x"), { code }); + Reflect.set(OutgoingMessage.prototype, "write", write); + deepStrictEqual( + response.destroyed, + code === "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + code, + ); + } + } finally { + Reflect.set(OutgoingMessage.prototype, "write", write); + } + }); + + test("for a head that Node.js refuses (left uncommitted) leaves the response to its next reply", async () => { + const app = new Binden().use(({ response }: Context) => { + response.setHeader("Trailer", "X-T"); + response.setHeader("Content-Length", 4); + try { + response.end("body"); + } catch { + ok(!response.headersSent && !response.destroyed); + response.removeHeader("Trailer"); + response.end("next"); + } + }); + const listening = await listen(app); + try { + deepStrictEqual( + await converse( + listening.port, + "GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ), + { + data: "HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\nnext", + closed: true, + }, + ); + } finally { + await listening.close(); + } + }); +}); + +suite("The first write() of a body past its strict Content-Length", () => { + /** A strict response of `Content-Length: 2` */ + const strict = ( + request = new IncomingMessage(new Socket()), + ): BindenResponse => { + const response = new BindenResponse(request); + response.strictContentLength = true; + response.setHeader("Content-Length", 2); + return response; + }; + + test("throws the error of Node.js before Node.js gets it: nothing is committed, and the response stays usable", async () => { + const outcomes: unknown[] = []; + const app = new Binden().use(async ({ response, url }: Context) => { + if (url.pathname === "/next") { + response.end("next"); + return; + } + response.strictContentLength = true; + response.setHeader("Content-Length", 2); + try { + response.write(`ab${injected}`); + } catch (error: unknown) { + const { code, message } = error as { code?: unknown; message: string }; + outcomes.push(code, message, response.headersSent, response.destroyed); + } + // Once Node.js would have flushed it + await sleep(10); + response.end("ab"); + }); + const listening = await listen(app); + try { + deepStrictEqual(await converse(listening.port, pipelined), { + data: + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\nab" + + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\nnext", + closed: true, + }); + deepStrictEqual(outcomes, [ + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + `Response body's content-length of ${2 + injected.length} byte(s) does not match the content-length of 2 byte(s) set in header`, + false, + false, + ]); + } finally { + await listening.close(); + } + }); + + test("thrown out of the middleware: the error is replied, where Node.js sends the injected response", async () => { + const handler = async ( + request: IncomingMessage, + response: ServerResponse, + ): Promise => { + if (request.url === "/next") { + response.end("next"); + return; + } + response.strictContentLength = true; + response.setHeader("Content-Length", 2); + response.write(`ab${injected}`); + await sleep(10); + response.end(); + }; + const node = createServer((request, response) => { + handler(request, response).catch(() => { + response.destroy(); + }); + }); + const app = new Binden().use(async ({ request, response }: Context) => { + await handler(request, response); + }); + const conversations: IConversation[] = []; + for (const server of [node, app.createServer()]) { + const listening = await serve(server); + try { + conversations.push(await converse(listening.port, pipelined)); + } finally { + await listening.close(); + } + } + const [fromNode, fromBinden] = conversations as [ + IConversation, + IConversation, + ]; + // The gap of Node.js: the excess of its first chunk is a response + ok(fromNode.data.endsWith(`\r\n\r\nab${injected}`), fromNode.data); + deepStrictEqual(fromBinden, { + data: + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\n" + + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\nnext", + closed: true, + }); + }); + + test("is checked as Node.js checks the chunks that follow the head", () => { + const head = new IncomingMessage(new Socket()); + head.method = "HEAD"; + /** Writes, then tells what has thrown, and whether the head is committed or the response destroyed */ + const outcome = ( + response: BindenResponse, + ...args: [unknown, BufferEncoding?] + ): unknown[] => { + try { + response.write(...(args as [unknown])); + return [null, response.headersSent, response.destroyed]; + } catch (error: unknown) { + return [ + (error as { code?: unknown }).code, + response.headersSent, + response.destroyed, + ]; + } + }; + const refused = "ERR_HTTP_CONTENT_LENGTH_MISMATCH"; + const cases: [string, () => unknown[], unknown[]][] = [ + [ + "past it", + (): unknown[] => outcome(strict(), "abc"), + [refused, false, false], + ], + ["of it", (): unknown[] => outcome(strict(), "ab"), [null, true, false]], + [ + "of bytes past it", + (): unknown[] => outcome(strict(), new Uint8Array(3)), + [refused, false, false], + ], + // In the bytes of its encoding + [ + "of two bytes in UTF-8", + (): unknown[] => outcome(strict(), "é"), + [null, true, false], + ], + [ + "of three bytes in UTF-8", + (): unknown[] => outcome(strict(), "€"), + [refused, false, false], + ], + [ + "of four bytes in UTF-16", + (): unknown[] => outcome(strict(), "ab", "utf16le"), + [refused, false, false], + ], + [ + "of two bytes in latin1", + (): unknown[] => outcome(strict(), "éé", "latin1"), + [null, true, false], + ], + // Not checked by Node.js + [ + "without strictContentLength", + (): unknown[] => { + const response = strict(); + response.strictContentLength = false; + return outcome(response, "abc"); + }, + [null, true, false], + ], + // As Node.js reads it: also an array of equal values (the last one) + [ + "of spaces before it", + (): unknown[] => + outcome(strict().setHeader("Content-Length", " 2"), "abc"), + [refused, false, false], + ], + [ + "of a zero before it", + (): unknown[] => + outcome(strict().setHeader("Content-Length", "02"), "abc"), + [refused, false, false], + ], + [ + "of an array of it", + (): unknown[] => + outcome(strict().setHeader("Content-Length", ["2"]), "abc"), + [refused, false, false], + ], + [ + "of an array of equal values past it", + (): unknown[] => + outcome(strict().setHeader("Content-Length", ["2", "2"]), "abc"), + [refused, false, false], + ], + [ + "of an array of equal values, within it", + (): unknown[] => + outcome(strict().setHeader("Content-Length", ["2", " 2"]), "ab"), + [null, true, false], + ], + [ + "of appended equal numbers past it", + (): unknown[] => { + const response = strict(); + // @ts-expect-error -- a number, which Node.js keeps as it is + response.appendHeader("Content-Length", 2); + return outcome(response, "abc"); + }, + [refused, false, false], + ], + [ + "without a valid Content-Length", + (): unknown[] => + outcome(strict().setHeader("Content-Length", "2x"), "abc"), + [null, true, false], + ], + // Node.js sends no `Content-Length` line + [ + "of an empty array", + (): unknown[] => { + const response = strict().setHeader("Content-Length", []); + return [ + ...outcome(response, "abc"), + String(Reflect.get(response, "_header")).includes("Content-"), + ]; + }, + [null, true, false, false], + ], + [ + "of a chunked body", + (): unknown[] => + outcome(strict().setHeader("Transfer-Encoding", "chunked"), "abc"), + [null, true, false], + ], + [ + "to HEAD", + (): unknown[] => outcome(strict(head), "abc"), + [null, true, false], + ], + [ + "with 204", + (): unknown[] => outcome(strict().status(204), "abc"), + [null, true, false], + ], + // By Node.js itself once the head is committed: destroyed + [ + "once the head is committed", + (): unknown[] => { + const response = strict(); + response.flushHeaders(); + return outcome(response, "abc"); + }, + [refused, true, true], + ], + ]; + for (const [name, run, expected] of cases) { + deepStrictEqual(run(), expected, name); + } + }); +}); + +suite("Content-Length values that disagree", () => { + /** A body whose excess a client that reads the first value would read as the next response */ + const body = `ab${injected}`; + /** Values that disagree, given in a way, then the call that commits the head */ + const disagreeing: Record void> = { + "an array set, then write()": (response) => { + response.setHeader("Content-Length", ["2", String(body.length)]); + response.write(body); + }, + "two appendHeader(), then write()": (response) => { + response.appendHeader("Content-Length", "2"); + response.appendHeader("Content-Length", String(body.length)); + response.write(body); + }, + "two appendHeader() under strictContentLength, then write()": ( + response, + ) => { + response.strictContentLength = true; + // The last one, which Node.js would check the next chunks against + response.appendHeader("Content-Length", String(body.length)); + response.appendHeader("Content-Length", "2"); + response.write(body); + }, + "equal values that are not lengths, then write()": (response) => { + response.setHeader("Content-Length", ["0x2", "0x2"]); + response.write("ab"); + }, + "values past the safe integers, then write()": (response) => { + response.setHeader("Content-Length", [ + "9007199254740993", + "9007199254740992", + ]); + response.write("ab"); + }, + "an array set, then end() with a chunk": (response) => { + response.setHeader("Content-Length", ["2", String(body.length)]); + response.end(body); + }, + "an array set, then end()": (response) => { + response.setHeader("Content-Length", ["0", "2"]); + response.end(); + }, + "an array set, then flushHeaders()": (response) => { + response.setHeader("Content-Length", ["2", "3"]); + response.flushHeaders(); + }, + "an array given to writeHead()": (response) => { + response.writeHead(200, { + "Content-Length": ["2", String(body.length)], + }); + }, + "pairs given to writeHead()": (response) => { + response.writeHead(200, [ + "Content-Length", + "2", + "content-length", + String(body.length), + ]); + }, + "nested pairs given to writeHead() with a reason phrase": (response) => { + response.writeHead(200, "OK", [ + ["Content-Length", "2"], + ["Content-Length", "3"], + ]); + }, + }; + /** + * Lists, which Node.js cannot read, also of members that agree (with each + * other and with the body), given in a way, then the call that commits the + * head + */ + const lists: Record void> = { + "a list, then write()": (response) => { + response.setHeader("Content-Length", `2, ${body.length}`); + response.write(body); + }, + "a list of equal lengths, then write()": (response) => { + response.setHeader("Content-Length", "2, 2"); + response.write(body); + }, + "a list of the length of the body, then end() with it": (response) => { + response.setHeader("Content-Length", "3, 3"); + response.end("abc"); + }, + "a list without a space, then end() with a chunk": (response) => { + response.setHeader("Content-Length", "3,3"); + response.end("abc"); + }, + "an array with a list of equal lengths, then end() with a chunk": ( + response, + ) => { + response.setHeader("Content-Length", ["3", "3, 3"]); + response.end("abc"); + }, + "an array with a list of an empty member, then write()": (response) => { + response.setHeader("Content-Length", ["2", "2,"]); + response.write(body); + }, + "a list given to writeHead()": (response) => { + response.setHeader("Content-Length", "3"); + response.writeHead(200, { "Content-Length": "3, 3" }); + }, + "a list appended, then flushHeaders()": (response) => { + response.appendHeader("Content-Length", "3"); + response.appendHeader("Content-Length", "3,3"); + response.flushHeaders(); + }, + }; + /** The message of the refusal of each case */ + const messages = new Map([ + ...Object.keys(disagreeing).map( + (name) => [name, "Content-Length values disagree"] as const, + ), + ...Object.keys(lists).map( + (name) => [name, "Content-Length is a list"] as const, + ), + ]); + const refused = { ...disagreeing, ...lists }; + + test("and lists are refused before anything is committed: the call that would commit the head throws, and the error is replied, where Node.js sends them", async () => { + const outcomes: unknown[] = []; + const app = new Binden().use(({ response, url }: Context) => { + const name = decodeURIComponent(url.pathname.slice(1)); + if (name === "next") { + response.end("next"); + return; + } + try { + refused[name](response); + } catch (error: unknown) { + const { code, message } = error as { code?: unknown; message: string }; + outcomes.push([ + name, + code, + message, + response.headersSent, + response.destroyed, + ]); + throw error; + } + }); + const listening = await listen(app); + try { + for (const name of Object.keys(refused)) { + deepStrictEqual( + await converse( + listening.port, + `GET /${encodeURIComponent(name)} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ), + { + data: + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\n" + + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\nnext", + closed: true, + }, + name, + ); + } + deepStrictEqual( + outcomes, + Object.keys(refused).map((name) => [ + name, + "ERR_HTTP_INVALID_HEADER_VALUE", + messages.get(name), + false, + false, + ]), + ); + } finally { + await listening.close(); + } + // Node.js sends both values, and frames the body by the last one + const response = new ServerResponse(new IncomingMessage(new Socket())); + response.setHeader("Content-Length", ["2", "3"]); + response.flushHeaders(); + ok( + String(Reflect.get(response, "_header")).includes( + "Content-Length: 2\r\nContent-Length: 3\r\n", + ), + ); + deepStrictEqual(Reflect.get(response, "_contentLength"), 3); + // Node.js sends a list as it is, and cannot read it (even of members + // that agree): it does not know the length of the body + const list = new ServerResponse(new IncomingMessage(new Socket())); + list.setHeader("Content-Length", "3, 3"); + list.flushHeaders(); + ok( + String(Reflect.get(list, "_header")).includes("Content-Length: 3, 3\r\n"), + ); + deepStrictEqual(Reflect.get(list, "_contentLength"), Number.NaN); + }); + + test("and lists declare no length (framingLength(), by which @binden/compression decides as well): equal values are that length, an empty array is none", () => { + const cases: [Parameters[0], number | null][] = [ + ["100", 100], + [100, 100], + [" 100 ", 100], + [["100"], 100], + [["100", "100"], 100], + [["100", "0100"], 100], + [[], null], + [new OutgoingMessage().getHeader("Content-Length"), null], + [["100", "101"], null], + ["100, 100", null], + ["100,100", null], + [["100", "100, 100"], null], + ["-1", null], + ["1.5", null], + ["abc", null], + ["9007199254740992", null], + ]; + for (const [value, length] of cases) { + deepStrictEqual(framingLength(value), length, JSON.stringify(value)); + } + }); + + test("given to writeHead() replace the ones set, as Node.js does: only the values of the head are checked", () => { + const sent = new BindenResponse(new IncomingMessage(new Socket())); + sent.setHeader("Content-Length", ["2", "3"]); + sent.writeHead(200, { "Content-Length": ["2", "2"] }); + deepStrictEqual( + [sent.headersSent, sent.getHeader("content-length")], + [true, ["2", "2"]], + ); + const kept = new BindenResponse(new IncomingMessage(new Socket())); + kept.setHeader("Content-Length", "2"); + throws( + () => kept.writeHead(200, ["Content-Length", "2", "Content-Length", "3"]), + { code: "ERR_HTTP_INVALID_HEADER_VALUE" }, + ); + // Nothing is changed + deepStrictEqual( + [kept.headersSent, kept.getHeader("content-length")], + [false, "2"], + ); + // Without a value given, the ones set (none: as many as Node.js sends) + const none = new BindenResponse(new IncomingMessage(new Socket())); + none.setHeader("Content-Length", []); + none.writeHead(200, { "X-Length": ["2", "3"] }); + deepStrictEqual(none.headersSent, true); + }); + + test("equal ones are that length: a first write() past it under strictContentLength is refused, and the error replied, where Node.js sends the excess", async () => { + const handler = async ( + request: IncomingMessage, + response: ServerResponse, + ): Promise => { + if (request.url === "/next") { + response.end("next"); + return; + } + response.strictContentLength = true; + response.setHeader("Content-Length", ["2", "2"]); + response.write(body); + await sleep(10); + response.end(); + }; + const node = createServer((request, response) => { + handler(request, response).catch(() => { + response.destroy(); + }); + }); + const app = new Binden().use(async ({ request, response }: Context) => { + await handler(request, response); + }); + const conversations: IConversation[] = []; + for (const server of [node, app.createServer()]) { + const listening = await serve(server); + try { + conversations.push(await converse(listening.port, pipelined)); + } finally { + await listening.close(); + } + } + const [fromNode, fromBinden] = conversations as [ + IConversation, + IConversation, + ]; + ok( + fromNode.data.includes(`Content-Length: 2\r\nContent-Length: 2\r\n`) && + fromNode.data.endsWith(`\r\n\r\n${body}`), + fromNode.data, + ); + deepStrictEqual(fromBinden, { + data: + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\n" + + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\nnext", + closed: true, + }); + }); +}); + +suite("A Content-Length of another type (F1)", () => { + /** `undefined`, which the linter does not let the tests write */ + const absent = ({} as { value?: undefined }).value; + /** The conversions of `counting` */ + let conversions = 0; + /** An object whose conversion is a length, which Node.js would send */ + const counting = { + toString(): string { + conversions += 1; + return "2"; + }, + }; + /** An array of two lengths with a hole between them */ + const holed = (): string[] => { + const values = ["2"]; + values[2] = "2"; + return values; + }; + /** Flat pairs whose value is a hole */ + const holedPair = (): string[] => { + const pairs = ["Content-Length"]; + pairs.length = 2; + return pairs; + }; + /** A source that is never read */ + const pending = (): Readable => + new Readable({ + read(): void { + // Never + }, + }); + /** A value set or given in a way, then the call that commits the head, and how it is described */ + const cases: Record unknown, string]> = + { + "an object whose conversion is a length, then write()": [ + (response): unknown => { + response.setHeader("Content-Length", counting as never); + conversions = 0; + return response.write("ab"); + }, + "(an object)", + ], + "an object, then write() under strictContentLength": [ + (response): unknown => { + response.strictContentLength = true; + response.setHeader("Content-Length", counting as never); + conversions = 0; + return response.write("ab"); + }, + "(an object)", + ], + "true, then end() with a chunk": [ + (response): unknown => + response.setHeader("Content-Length", true as never).end("ab"), + "true", + ], + "null, then end()": [ + (response): unknown => + response.setHeader("Content-Length", null as never).end(), + "null", + ], + "a bigint, then flushHeaders()": [ + (response): void => { + response.setHeader("Content-Length", 2n as never).flushHeaders(); + }, + "2n", + ], + "an array of an object, then writeHead()": [ + (response): unknown => { + response.setHeader("Content-Length", [counting] as never); + conversions = 0; + return response.writeHead(200); + }, + "(an object)", + ], + "an array with a hole, then write()": [ + (response): unknown => + response.setHeader("Content-Length", holed()).write("ab"), + "undefined", + ], + "two values appended, one undefined, then end()": [ + (response): unknown => + response + .appendHeader("Content-Length", ["2", absent] as never) + .end("ab"), + "undefined", + ], + "an object given to writeHead()": [ + (response): unknown => { + conversions = 0; + return response.writeHead(200, { + "Content-Length": counting as never, + }); + }, + "(an object)", + ], + "undefined given to writeHead() in place of a valid one set": [ + (response): unknown => + response + .setHeader("Content-Length", 2) + .writeHead(200, "OK", { "content-length": absent as never }), + "undefined", + ], + "an array with a hole given to writeHead()": [ + (response): unknown => + response.writeHead(200, { "Content-Length": holed() }), + "undefined", + ], + "a hole of pairs given to writeHead()": [ + (response): unknown => response.writeHead(200, holedPair()), + "undefined", + ], + "an object, then send() of a Readable": [ + (response): unknown => { + response.setHeader("Content-Length", counting as never); + conversions = 0; + return response.send(pending()); + }, + "(an object)", + ], + "an array with a hole, then send() of a Readable": [ + (response): unknown => { + response.setHeader("Content-Length", holed()); + return response.send(pending()); + }, + "undefined", + ], + // Of a length of its own, which replaces it: sent + "an object, then send() of a string": [ + (response): unknown => { + response.setHeader("Content-Length", counting as never); + return response.send("ab"); + }, + "", + ], + "true, then sendFile()": [ + (response): unknown => { + response.setHeader("Content-Length", true as never); + return response.sendFile(import.meta.filename, { + content_type: "text/plain", + }); + }, + "", + ], + }; + + test("is refused before anything is committed, never converted: the call that would commit the head throws (a send rejects), and the error is replied on a connection that is reused, where Node.js sends it converted", async () => { + const outcomes: unknown[] = []; + const app = new Binden().use(async ({ response, url }: Context) => { + const name = decodeURIComponent(url.pathname.slice(1)); + if (name === "next") { + response.end("next"); + return; + } + const [run] = cases[name] as [ + (response: BindenResponse) => unknown, + string, + ]; + try { + await run(response); + } catch (error: unknown) { + const { code, message } = error as { code?: unknown; message: string }; + outcomes.push([ + name, + error instanceof TypeError, + code, + message, + response.headersSent, + response.destroyed, + conversions, + ]); + throw error; + } + }); + const listening = await listen(app); + try { + for (const [name, [, described]] of Object.entries(cases)) { + const conversation = await converse( + listening.port, + `GET /${encodeURIComponent(name)} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + if (described === "") { + // Sent, by its own length + ok( + conversation.data.startsWith("HTTP/1.1 200 OK") && + /\r\nContent-Length: \d+\r\n/u.test(conversation.data) && + conversation.data.endsWith("\r\n\r\nnext"), + name, + ); + } else { + deepStrictEqual( + conversation, + { + data: + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\n" + + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\nnext", + closed: true, + }, + name, + ); + } + } + deepStrictEqual( + outcomes, + Object.entries(cases) + .filter(([, [, described]]) => described !== "") + .map(([name, [, described]]) => [ + name, + true, + "ERR_HTTP_INVALID_HEADER_VALUE", + `Invalid Content-Length: ${described}`, + false, + false, + 0, + ]), + ); + } finally { + await listening.close(); + } + // Node.js sends it converted, and frames the body by it + const node = new ServerResponse(new IncomingMessage(new Socket())); + node.setHeader("Content-Length", counting as never); + node.flushHeaders(); + ok(String(Reflect.get(node, "_header")).includes("Content-Length: 2\r\n")); + }); +}); + +suite("A response that waits for the one before it, destroyed", () => { + /** + * A server whose `/` replies after 50 ms, and whose `/next` (pipelined + * after it, so without a socket yet) is set up, then destroyed (with the + * rights of the framework, or not): what the client reads + */ + async function destroyQueued( + Response: typeof ServerResponse, + setup: (response: ServerResponse) => void, + framework: boolean, + ): Promise { + const server = createServer( + { ServerResponse: Response as typeof BindenResponse }, + (request: IncomingMessage, response: ServerResponse) => { + if (request.url === "/") { + setTimeout(() => { + response.end("first"); + }, 50); + return; + } + ok(response.socket === null); + setup(response); + if (framework) { + (response as BindenResponse)[kFramework](() => response.destroy()); + } else { + response.destroy(); + } + }, + ); + const listening = await serve(server); + try { + return await converse( + listening.port, + "GET / HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + } finally { + await listening.close(); + } + } + + const first = + "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 5\r\n\r\nfirst"; + + const setups: Record void> = { + "once started": (response) => { + response.write("part"); + }, + "before it has started": () => { + // Nothing written + }, + }; + + for (const [name, setup] of Object.entries(setups)) { + test(`by the framework ${name}: its connection is closed once it would be sent (on every Node.js)`, async () => { + deepStrictEqual(await destroyQueued(BindenResponse, setup, true), { + data: first, + closed: true, + }); + }); + + test(`by the application ${name}: as Node.js`, async () => { + deepStrictEqual( + await destroyQueued(BindenResponse, setup, false), + await destroyQueued(ServerResponse, setup, false), + ); + }); + } + + test("by the framework once ended: as Node.js (its complete reply is not cut by binden)", async () => { + const setup = (response: ServerResponse): void => { + response.end("next"); + }; + deepStrictEqual( + await destroyQueued(BindenResponse, setup, true), + await destroyQueued(ServerResponse, setup, false), + ); + }); + + test("by its deadline once it has started: the response before it is complete, then the connection is closed", async () => { + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + timeout: 50, + error_handler: async ({ request, response }: Context): Promise => { + if (request.url === "/") { + // Once the deadline of `/next` has passed + await sleep(40); + await response.status(503).send("late"); + } + }, + }).use(async ({ request, response }: Context) => { + if (request.url === "/next") { + response.write("part"); + } + await never(); + }); + const listening = await listen(app); + try { + deepStrictEqual( + await converse( + listening.port, + "GET / HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\n\r\n", + ), + { + data: "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: 4\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\nlate", + closed: true, + }, + ); + await captured.waitFor("The deadline has passed. Destroying the socket"); + } finally { + await listening.close(); + } + }); +}); diff --git a/packages/binden/test/deadline.test.ts b/packages/binden/test/deadline.test.ts new file mode 100644 index 00000000..f2454f8e --- /dev/null +++ b/packages/binden/test/deadline.test.ts @@ -0,0 +1,1368 @@ +import { type AddressInfo, connect } from "node:net"; +import { + Binden, + BindenAbortError, + BindenError, + type Context, + Cookie, + type IBindenOptions, + shutdown, +} from "../index.js"; +import { + type ICaptured, + type IListening, + capture, + listen, + never, + pollUntil, + send, + sleep, + within, +} from "./helpers.js"; +import { type IncomingMessage, createServer, get } from "node:http"; +import { PassThrough, Readable, finished } from "node:stream"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { once } from "node:events"; +import { pipeline } from "node:stream/promises"; +import { setTimeout as wait } from "node:timers/promises"; + +/** 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(); + } +} + +/** A connection that sends requests and reads their responses, one at a time */ +interface IClient { + /** Sends a request, and reads its response (framed by `Content-Length`) */ + request(path: string): Promise<{ head: string; body: string }>; + /** Whether the server has closed the connection */ + readonly closed: boolean; + close(): void; +} + +/** Opens a connection that is kept alive */ +async function client(port: number): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + let closed = false; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.on("close", () => { + closed = true; + }); + /** The end of the first response received, once it is complete */ + const framed = (): number | null => { + const end = data.indexOf("\r\n\r\n"); + const length = /^Content-Length: (?\d+)$/imu.exec( + data.slice(0, end), + )?.groups?.length; + return end !== -1 && + typeof length !== "undefined" && + data.length >= end + 4 + Number(length) + ? end + 4 + Number(length) + : null; + }; + const response = async (): Promise<{ head: string; body: string }> => { + await pollUntil(() => closed || framed() !== null).catch(() => null); + const complete = framed(); + if (complete === null) { + throw new Error(`No complete response: ${JSON.stringify(data)}`); + } + const end = data.indexOf("\r\n\r\n"); + const head = data.slice(0, end); + const body = data.slice(end + 4, complete); + data = data.slice(complete); + return { head, body }; + }; + return { + request: (path: string): Promise<{ head: string; body: string }> => { + socket.write(`GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`); + return response(); + }, + get closed(): boolean { + return closed; + }, + close: (): void => { + socket.destroy(); + }, + }; +} + +/** Whether the error is the `503` of a deadline */ +function isDeadline(error: unknown): boolean { + return error instanceof BindenError && error.code === "deadline"; +} + +/** + * A promise that settles synchronously from a listener of the abort of the + * request (before the framework learns of it), as the path of the request + * says: it rejects (`/rejects`), resolves, or resolves and then throws from its + * own continuation (`/throws`) + */ +function settledAtTheDeadline(context: Context): Promise { + const { pathname } = context.url; + const { promise, resolve, reject } = Promise.withResolvers(); + context.signal.addEventListener("abort", () => { + if (pathname === "/rejects") { + reject(new Error("Rejected at the deadline")); + } else { + (resolve as () => void)(); + } + }); + return pathname === "/throws" + ? promise.then(() => { + throw new Error("Thrown at the deadline"); + }) + : promise; +} + +/** A source that never ends (nor pushes anything by itself) */ +function silent(): Readable { + return new Readable({ + read(): void { + // Nothing + }, + }); +} + +/** + * Writes requests on one connection (the last one with `Connection: close`) + * and reads everything until the server closes it: the responses, each + * framed exactly by its `Content-Length` or its chunks + */ +async function converse( + port: number, + requests: string, +): Promise<{ head: string; body: string }[]> { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write(requests); + await within(once(socket, "close")); + const responses: { head: string; body: string }[] = []; + while (data !== "") { + const end = data.indexOf("\r\n\r\n"); + const head = data.slice(0, end); + let rest = data.slice(end + 4); + const length = /^Content-Length: (?\d+)$/imu.exec(head)?.groups + ?.length; + let body = ""; + if (/^Transfer-Encoding: chunked$/imu.test(head)) { + for (;;) { + const line = rest.indexOf("\r\n"); + const size = Number.parseInt(rest.slice(0, line), 16); + ok(line > 0 && Number.isSafeInteger(size), `Not chunked: ${data}`); + body += rest.slice(line + 2, line + 2 + size); + rest = rest.slice(line + 2 + size + 2); + if (size === 0) { + break; + } + } + } else { + ok(typeof length !== "undefined", `Not framed: ${data}`); + body = rest.slice(0, Number(length)); + rest = rest.slice(Number(length)); + } + responses.push({ head, body }); + data = rest; + } + return responses; +} + +suite("The deadline", () => { + test("seals the response before its signal is aborted", async () => { + const paths = [ + "/pipeline", + "/listener", + "/headers", + "/caught", + "/caught-large", + ]; + await withApp( + { timeout: 30 }, + (app) => { + app.use(async (context: Context) => { + const { response, signal } = context; + switch (context.url.pathname) { + case "/pipeline": + // Its first chunk comes after the deadline: the abort destroys + // the destination (the response) as well + await pipeline( + Readable.from( + (async function* produce(): AsyncGenerator { + await sleep(200); + yield "late"; + })(), + ), + response, + { signal }, + ); + break; + case "/listener": + signal.addEventListener("abort", () => { + response.statusCode = 200; + response.end("listener"); + }); + await never(); + break; + case "/headers": + signal.addEventListener("abort", () => { + response.setHeader("X-Listener", "1"); + response.cookies.add(new Cookie({ key: "late", value: "1" })); + }); + await never(); + break; + case "/caught": + default: + try { + await wait(1000, null, { signal }); + } catch { + // The work stops: its reply, once the signal is aborted + } + await context + .status(504) + .text( + context.url.pathname === "/caught" + ? "caught" + : "x".repeat(4 * 1024 * 1024), + ); + } + }); + }, + async ({ port }) => { + for (const path of paths) { + const connection = await client(port); + try { + const { head, body } = await connection.request(path); + ok(head.startsWith("HTTP/1.1 503 "), `${path}: ${head}`); + ok(!/^(?:X-Listener|Set-Cookie)/imu.test(head), head); + deepStrictEqual(body, "", path); + } finally { + connection.close(); + } + } + }, + ); + }); + + test("takes the reply over from an error handler that runs, whatever it does later", async () => { + const paths = [ + "/hangs", + "/stream", + "/throws-late", + "/replies-late", + "/expires", + ]; + const cancelled: unknown[] = []; + await withApp( + { + timeout: 40, + error_handler_timeout: 300, + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + if (error instanceof BindenError && error.code === "deadline") { + await context.status(503).send("deadline"); + return; + } + switch (context.url.pathname) { + case "/hangs": + await never(); + break; + case "/stream": + // Its first chunk never comes: the deadline cancels it + await context.send(silent()).catch((reason: unknown) => { + cancelled.push(reason); + }); + await context.status(500).send("late"); + break; + case "/throws-late": + await sleep(100); + throw new Error("A late failure"); + case "/replies-late": + await sleep(100); + await context.status(500).send("late"); + break; + case "/expires": + default: + // Longer than `error_handler_timeout` + await sleep(400); + } + }, + }, + (app) => { + app.use((context: Context) => { + if (context.url.pathname === "/ok") { + return context.send("ok"); + } + throw new Error("A failure"); + }); + }, + async ({ port }, captured) => { + for (const path of paths) { + const connection = await client(port); + try { + const start = Date.now(); + const { head, body } = await connection.request(path); + ok(head.startsWith("HTTP/1.1 503 "), `${path}: ${head}`); + deepStrictEqual(body, "deadline", path); + // At once, not once `error_handler_timeout` has passed + ok(Date.now() - start < 250, `${path}: ${Date.now() - start} ms`); + // The later outcome of the handler changes nothing: the connection + // is neither cut nor given another reply + await sleep(450); + ok(!connection.closed, path); + deepStrictEqual((await connection.request("/ok")).body, "ok"); + } finally { + connection.close(); + } + } + deepStrictEqual(cancelled.length, 1); + ok( + cancelled[0] instanceof BindenAbortError && + cancelled[0].reason === "deadline", + ); + const messages = captured.records.map(({ msg }) => msg); + deepStrictEqual( + messages.filter( + (msg) => + msg === + "The provided `error_handler` has been abandoned at the deadline", + ).length, + paths.length, + ); + for (const message of [ + "The provided `error_handler` has not settled in time", + "The provided `error_handler` has failed", + ]) { + ok(!messages.includes(message), message); + } + await within( + captured.waitFor("The provided `error_handler` has failed late"), + ); + }, + ); + }); + + test("takes the reply over from an error handler that settles as the deadline passes", async () => { + const paths = ["/rejects", "/resolves", "/throws"]; + await withApp( + { + timeout: 30, + error_handler_timeout: 1000, + // Not `async`: its promise is the one that settles at the deadline + error_handler: (context: Context, error: unknown): Promise => { + if (isDeadline(error)) { + // Its reply comes later than the outcome of the handler + return sleep(20).then(async () => { + await context.status(503).send("deadline"); + }); + } + return settledAtTheDeadline(context); + }, + }, + (app) => { + app.use(() => { + throw new Error("A failure"); + }); + }, + async ({ port }, captured) => { + for (const path of paths) { + const connection = await client(port); + try { + const { head, body } = await connection.request(path); + ok(head.startsWith("HTTP/1.1 503 "), `${path}: ${head}`); + deepStrictEqual(body, "deadline", path); + } finally { + connection.close(); + } + } + const messages = captured.records.map(({ msg }) => msg); + deepStrictEqual( + messages.filter( + (msg) => msg === "The provided `error_handler` has failed late", + ).length, + 2, + ); + ok(!messages.includes("The provided `error_handler` has failed")); + }, + ); + }); + + test("stops the stack whose middleware settles from a listener of the abort", async () => { + let next = false; + await withApp( + { timeout: 20 }, + (app) => { + app + .use( + (context: Context) => + new Promise((resolve) => { + context.signal.addEventListener("abort", () => { + resolve(); + }); + }), + ) + .use(() => { + next = true; + }); + }, + async ({ port }) => { + const connection = await client(port); + try { + const { head } = await connection.request("/"); + ok(head.startsWith("HTTP/1.1 503 "), head); + } finally { + connection.close(); + } + await sleep(20); + ok(!next); + }, + ); + }); + + test("cancels a send that has not committed its headers, as the time out of an error handler does", async () => { + let rejection: unknown = null; + await withApp( + { + timeout: 60, + error_handler_timeout: 30, + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + if (context.url.pathname === "/handler" && !isDeadline(error)) { + // Started (see `response.started`) until its time out cancels it + await context.send(silent()).catch((reason: unknown) => { + rejection = reason; + }); + return; + } + await context.status(isDeadline(error) ? 503 : 500).send("reply"); + }, + }, + (app) => { + app.use(async (context: Context) => { + if (context.url.pathname === "/handler") { + context.setTimeout(0); + throw new Error("A failure"); + } + // The deadline cancels it, and replies + await context.send(silent()); + }); + }, + async ({ port }) => { + const connection = await client(port); + try { + const { head, body } = await connection.request("/middleware"); + ok(head.startsWith("HTTP/1.1 503 "), head); + deepStrictEqual(body, "reply"); + } finally { + connection.close(); + } + // Replaced by the opaque `500`, on a connection that stays open + const replaced = await client(port); + try { + const { head, body } = await replaced.request("/handler"); + ok(head.startsWith("HTTP/1.1 500 "), head); + deepStrictEqual([body, replaced.closed], ["", false]); + } finally { + replaced.close(); + } + }, + ); + ok(rejection instanceof BindenError); + deepStrictEqual( + [rejection.status, rejection.code], + [500, "error_handler_timeout"], + ); + }); + + test("a send that its source ends at the deadline leaves no framing to the reply that follows", async () => { + const upstream = createServer((_request, response) => { + // The head of a body that never comes + response.writeHead(200, { "Content-Length": 100_000 }).flushHeaders(); + }); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const { port: up } = upstream.address() as AddressInfo; + const rejected: unknown[] = []; + try { + await withApp( + { + timeout: 50, + error_handler: async (context: Context, error: unknown) => { + if (!isDeadline(error)) { + throw error; + } else if (context.url.pathname.endsWith("/native")) { + context.response + .writeHead(503, { "Content-Type": "text/plain" }) + .end("busy\n"); + return; + } + context.response + .status(503) + .setHeader("Content-Type", "text/plain"); + // Of an unknown length: chunked, unless a length is left over + await context.send(Readable.from(["busy\n"])); + }, + }, + (app) => { + app.use(async (context: Context) => { + const { response, signal, url } = context; + if (url.pathname === "/next") { + await context.send("next"); + return; + } + let source: Readable = new PassThrough(); + if (url.pathname.startsWith("/proxy")) { + // A proxy of the upstream, which forwards its length + source = await new Promise((resolve, reject) => { + get({ host: "127.0.0.1", port: up, signal }, resolve).once( + "error", + reject, + ); + }); + response.setHeader( + "Content-Length", + `${(source as IncomingMessage).headers["content-length"]}`, + ); + } else { + response.setHeader("Content-Length", 123_456); + } + // Its source is destroyed at once by the abort of the deadline, + // before the deadline cancels the send + signal.addEventListener("abort", () => { + source.destroy(); + }); + await context.send(source).catch((error: unknown) => { + rejected.push(error); + }); + }); + }, + async ({ port }) => { + for (const path of [ + "/stream", + "/native", + "/proxy", + "/proxy/native", + ]) { + rejected.length = 0; + const responses = await converse( + port, + `GET ${path} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + deepStrictEqual( + responses.map(({ head, body }) => [head.split("\r\n")[0], body]), + [ + ["HTTP/1.1 503 Service Unavailable", "busy\n"], + ["HTTP/1.1 200 OK", "next"], + ], + path, + ); + ok(!/^Content-Length/imu.test(responses[0]?.head ?? ""), path); + // As a send that the deadline cancels + ok(rejected[0] instanceof BindenAbortError, path); + deepStrictEqual(rejected[0].reason, "deadline", path); + } + }, + ); + } finally { + upstream.closeAllConnections(); + upstream.close(); + } + }); + + test("a send that the client leaves rejects with its own error (its owner may still reply)", async () => { + const rejected: unknown[] = []; + const sending = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.response.setHeader("Content-Length", 10); + const sent = context.send(silent()); + sending.resolve(null); + await sent.catch((error: unknown) => { + rejected.push(error); + }); + }); + }, + async ({ port }) => { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + await within(sending.promise); + socket.destroy(); + await pollUntil(() => rejected.length > 0).catch(() => null); + deepStrictEqual( + (rejected[0] as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + }, + ); + }); + + test("a send that fails late leaves the framing of the reply that has replaced it", async () => { + const outcomes: unknown[] = []; + await withApp( + { + error_handler: async (context: Context) => { + // The framing of this reply, while the failed send still runs + context.response.statusMessage = ""; + context.response.status(500).setHeader("Content-Length", 4); + await sleep(60); + context.response.write("bu"); + context.response.end("sy"); + }, + }, + (app) => { + app.use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.send("next"); + return; + } + const source = new PassThrough(); + context.response.setHeader("Content-Length", 50); + context.send(source).then( + () => outcomes.push("sent"), + (error: unknown) => outcomes.push((error as Error).message), + ); + setTimeout(() => { + source.destroy(new Error("Late")); + }, 20); + throw new Error("A failure"); + }); + }, + async ({ port }) => { + const responses = await converse( + port, + "GET /late HTTP/1.1\r\nHost: a\r\n\r\nGET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + deepStrictEqual(outcomes, ["Late"]); + const [replaced, next] = responses as [ + { head: string; body: string }, + { head: string; body: string }, + ]; + ok(/^Content-Length: 4$/imu.test(replaced.head), replaced.head); + ok(!/^Transfer-Encoding/imu.test(replaced.head)); + deepStrictEqual(replaced.body, "busy"); + deepStrictEqual(next.body, "next"); + }, + ); + }); + + test("the abandoned work cannot change how the reply of the deadline is framed", async () => { + /** The framing properties of Node.js, and what the abandoned work sets */ + const assigned: Record = { + chunkedEncoding: true, + maxRequestsOnConnectionReached: true, + strictContentLength: true, + useChunkedEncodingByDefault: false, + }; + /** The replies of the deadline: the default one, a native one, a stream */ + const handlers: Record< + string, + [IBindenOptions["error_handler"], string, string] + > = { + default: [({} as { value?: undefined }).value, "Content-Length: 0", ""], + native: [ + async ({ response }: Context): Promise => { + response.writeHead(503, { "Content-Length": 4 }).write("busy"); + // While the abandoned work assigns them again + await sleep(30); + response.end(); + }, + "Content-Length: 4", + "busy", + ], + stream: [ + async (context: Context): Promise => { + context.response.status(503); + await context.send( + Readable.from( + (async function* produce(): AsyncGenerator { + yield "bu"; + await sleep(30); + yield "sy"; + })(), + ), + ); + }, + "Transfer-Encoding: chunked", + "busy", + ], + }; + for (const [property, value] of Object.entries(assigned)) { + for (const [name, [handler, framing, body]] of Object.entries(handlers)) { + await withApp( + { + timeout: 30, + ...(typeof handler === "undefined" + ? {} + : { error_handler: handler }), + }, + (app) => { + app.use(async (context: Context) => { + const { response, signal, url } = context; + if (url.pathname === "/next") { + // Queued behind the reply of the deadline: never cut by its own + context.setTimeout(0); + await context.send("next"); + return; + } + const assign = (): void => { + Reflect.set(response, property, value); + }; + signal.addEventListener("abort", () => { + // Strict once the reply has written its body (strict before, + // Node.js would count its bytes) + if (property !== "strictContentLength") { + assign(); + } + setTimeout(assign, 10); + }); + await never(); + }); + }, + async ({ port }) => { + const key = `${property} (${name})`; + const [replied, next] = (await converse( + port, + "GET / HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + )) as [ + { head: string; body: string }, + { head: string; body: string }, + ]; + const head = replied.head.split("\r\n"); + deepStrictEqual(head[0], "HTTP/1.1 503 Service Unavailable", key); + ok(head.includes(framing), `${key}: ${replied.head}`); + ok( + head.includes("Connection: keep-alive"), + `${key}: ${replied.head}`, + ); + deepStrictEqual(replied.body, body, key); + deepStrictEqual(next.body, "next", key); + }, + ); + } + } + }); + + suite("is changed only by the work that may reply", () => { + for (const [name, stack, handler, status] of [ + [ + "an early `close` listener of the failed stack removes it", + (context: Context): void => { + context.response.once("close", () => { + context.setTimeout(0); + }); + }, + null, + 503, + ], + [ + "an early `close` listener of the failed stack restarts it", + (context: Context): void => { + context.response.once("close", () => { + context.setTimeout(1000); + }); + }, + null, + 503, + ], + [ + "a later continuation of the failed stack removes it", + (context: Context): void => { + setTimeout(() => { + context.setTimeout(0); + }, 10); + }, + null, + 503, + ], + [ + "the error handler that replaces the stack removes it", + null, + (context: Context): void => { + context.setTimeout(0); + }, + 500, + ], + [ + "the error handler that replaces the stack restarts it", + null, + (context: Context): void => { + context.setTimeout(150); + }, + 500, + ], + ] as const) { + test(`${name}: ${status === 503 ? "ignored" : "applied"}`, async () => { + const checked: string[] = []; + await withApp( + { + timeout: 35, + error_handler_timeout: 1000, + error_handler: async (context: Context, error): Promise => { + if (isDeadline(error)) { + await context.status(503).send("deadline"); + return; + } + handler?.(context); + // Slower than the deadline + await sleep(75); + await context.status(500).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + stack?.(context); + context.response.once("close", () => { + // Not even checked + try { + context.setTimeout(-1); + checked.push("ignored"); + } catch { + checked.push("checked"); + } + }); + // Refused: the stack fails at once + const source = new PassThrough(); + source.on("newListener", () => null); + source.pipe(context.response); + await never(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual( + [reply.status, reply.body], + [status, status === 503 ? "deadline" : "handler"], + ); + deepStrictEqual(checked, ["ignored"]); + }, + ); + }); + } + }); + + for (const path of ["the option `timeout`", "`context.setTimeout()`"]) { + test(`runs its abort listeners as the abandoned stack, through ${path}`, async () => { + const events: string[] = []; + const done = Promise.withResolvers(); + await withApp( + { + timeout: path === "the option `timeout`" ? 30 : 0, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + await sleep(60); + events.push("reply"); + await context.status(503).send("deadline"); + }, + }, + (app) => { + app.use(async (context: Context) => { + if (path === "`context.setTimeout()`") { + context.setTimeout(30); + } + const { response } = context; + context.signal.addEventListener("abort", () => { + // Closed for them early, as for the stack + response.on("close", () => { + events.push("sync"); + }); + setImmediate(() => { + response.on("close", () => { + events.push("immediate"); + }); + }); + setTimeout(() => { + response.on("close", () => { + events.push("later"); + }); + finished(response, (error) => { + events.push(`finished ${String(error?.code)}`); + done.resolve(null); + }); + }, 15); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [503, "deadline"]); + await within(done.promise); + deepStrictEqual(events, [ + "sync", + "immediate", + "later", + "finished ERR_STREAM_PREMATURE_CLOSE", + "reply", + ]); + }, + ); + }); + } +}); + +suite("A complete reply that waits for the response before it", () => { + /** + * An application whose `/` never replies by itself, and whose `/next` ends + * its reply at once (or only writes a part of it), which then waits for the + * response of `/` (pipelined before it): whether the response of `/next` + * has finished once it is closed + */ + function queued( + options: IBindenOptions, + complete = true, + ): { + app: Binden; + captured: ICaptured; + ended: Promise; + closed: Promise; + } { + const captured = capture(); + const ended = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const app = new Binden({ ...options, logger: captured.logger }).use( + async (context: Context) => { + const { response } = context; + if (context.url.pathname !== "/next") { + await never(); + return; + } + context.onCleanup(() => { + closed.resolve(response.writableFinished); + }); + const sent = complete ? context.text("next") : never(); + if (!complete) { + response.write("part"); + } + // Without a connection yet + ok(response.writableEnded === complete && response.socket === null); + ended.resolve(null); + await sent; + }, + ); + return { app, captured, ended: ended.promise, closed: closed.promise }; + } + + const requests = + "GET / HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"; + + test("is sent once that response is, after its deadline (on every Node.js)", async () => { + const { app, captured, closed } = queued({ + timeout: 50, + access_log: true, + error_handler: async ({ response }: Context): Promise => { + // The reply of `/` comes once the deadline of `/next` has passed + await sleep(40); + await response.status(503).send("late"); + }, + }); + const listening = await listen(app); + try { + const responses = await converse(listening.port, requests); + deepStrictEqual( + responses.map(({ head, body }) => [head.split("\r\n")[0], body]), + [ + ["HTTP/1.1 503 Service Unavailable", "late"], + ["HTTP/1.1 200 OK", "next"], + ], + ); + // Finalized once it has been sent + deepStrictEqual(await within(closed), true); + const logged = captured.records.filter( + ({ msg }) => msg === "The request has completed", + ); + deepStrictEqual( + logged.map(({ status, aborted }) => [status, aborted]), + [ + [503, true], + [200, true], + ], + ); + await captured.waitFor( + "The deadline has passed once the response was complete. It is sent after the responses before it", + ); + } finally { + await listening.close(); + } + }); + + test("is sent once that response is, after a pipe of its stack has failed (on every Node.js)", async () => { + const captured = capture(); + const waiting = Promise.withResolvers(); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + const { response } = context; + if (context.url.pathname !== "/next") { + response.setHeader("Content-Length", 10).write("first"); + await sleep(60); + response.end("-done"); + return; + } + const source = new PassThrough(); + source.pipe(response); + // Added in the turn of the pipe: its throw fails the stack, once the + // pipe has ended the response, which waits for `/` + source.once("end", () => { + waiting.resolve(response.writableEnded && response.socket === null); + throw new Error("A listener of the end has failed"); + }); + source.end("next"); + await once(response, "close"); + }, + ); + const listening = await listen(app); + try { + const responses = await converse( + listening.port, + "GET / HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + ok(await within(waiting.promise)); + deepStrictEqual( + responses.map(({ head, body }) => [head.split("\r\n")[0], body]), + [ + ["HTTP/1.1 200 OK", "first-done"], + ["HTTP/1.1 200 OK", "next"], + ], + ); + const { failure } = await captured.waitFor( + "A pipe into the response has failed once its reply was complete. It is sent after the responses before it", + ); + const { type, location } = failure as Record; + deepStrictEqual([type, location], ["Error", "send"]); + } finally { + await listening.close(); + } + }); + + test( + "is not a complete reply that has its connection, which a pipe failure of its stack still cuts", + { + // Windows takes a whole write while its send buffer has room: the reply + // has finished before the pipe fails, so that there is nothing to cut + skip: process.platform === "win32", + }, + async () => { + const captured = capture(); + const size = 8 * 1024 * 1024; + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + const { response } = context; + const source = new PassThrough(); + source.pipe(response); + // Once the pipe has ended the response, which the client does not + // read yet + source.once("end", () => { + throw new Error("A listener of the end has failed"); + }); + source.end(Buffer.alloc(size, "a")); + await once(response, "close"); + }, + ); + const listening = await listen(app); + const socket = connect(listening.port, "127.0.0.1"); + try { + await once(socket, "connect"); + socket.pause(); + socket.write("GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + await captured.waitFor( + "A pipe into the response has failed once its headers were sent. Destroying the socket", + ); + let received = 0; + socket.on("data", (chunk: Buffer) => { + received += chunk.length; + }); + socket.resume(); + // Closed, not kept alive + await within(once(socket, "close")); + // Truncated + ok(received < size, `${received}`); + } finally { + socket.destroy(); + await listening.close(); + } + }, + ); + + test("is not a reply that has only started, which is still cut at its deadline", async () => { + const { app, captured, closed } = queued( + { + timeout: 50, + error_handler: async ({ + request, + response, + }: Context): Promise => { + if (request.url === "/") { + await sleep(40); + await response.status(503).send("late"); + } + }, + }, + false, + ); + const listening = await listen(app); + const socket = connect(listening.port, "127.0.0.1"); + try { + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write(requests); + // Destroyed: closed before it has finished (its connection is closed + // once it would be sent, see `cut.test.ts`) + deepStrictEqual(await within(closed), false); + await captured.waitFor("The deadline has passed. Destroying the socket"); + // The response before it is complete + await pollUntil(() => data.includes("\r\n\r\nlate")); + ok(data.startsWith("HTTP/1.1 503 Service Unavailable\r\n"), data); + } finally { + socket.destroy(); + await listening.close(); + } + }); + + test("is cut when the client goes away, on every Node.js", async () => { + const { app, captured, ended, closed } = queued({}); + const listening = await listen(app); + try { + const socket = connect(listening.port, "127.0.0.1"); + await once(socket, "connect"); + socket.write(requests); + await within(ended); + socket.destroy(); + // Closed by the framework once its connection has closed (Node.js + // 24.14 never closes it by itself) + deepStrictEqual(await within(closed), false); + const message = "The request has been aborted. Destroying the socket"; + await captured.waitFor(message); + // `/` and `/next` + deepStrictEqual( + captured.records + .filter(({ msg }) => msg === message) + .map(({ reason }) => reason), + ["client_closed", "client_closed"], + ); + } finally { + await listening.close(); + } + }); + + test("is cut by a shutdown that times out, on every Node.js", async () => { + const { app, captured, ended, closed } = queued({}); + const listening = await listen(app); + const socket = connect(listening.port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write(requests); + await within(ended); + const closing = shutdown(listening.server, { + timeout: 50, + cleanup_timeout: 100, + }).then( + () => null, + (error: unknown) => (error as Error).message, + ); + await within(once(socket, "close")); + deepStrictEqual(data, ""); + // Its cleanup runs once its connection has closed + deepStrictEqual(await within(closing), null); + deepStrictEqual(await within(closed), false); + // Both requests, at the end of the time of the shutdown + deepStrictEqual( + captured.records + .filter( + ({ msg }) => + msg === "The request has been aborted. Destroying the socket", + ) + .map(({ reason }) => reason), + ["shutdown", "shutdown"], + ); + }); +}); + +suite("A sealed reply that waits for the response before it", () => { + /** + * An application whose `/` replies only after `hold` milliseconds (without + * a deadline of its own), and whose `/next` (with the deadline of the + * application) ends its reply at once (`complete`, which is left to be + * sent once its deadline has passed) or never replies (`hangs`: the `503` + * of its deadline waits as well), and reads its body first (`read`): how + * the response of `/next` is closed, once + */ + function sealed( + kind: "complete" | "hangs", + hold: number, + ): { + app: Binden; + captured: ICaptured; + cleaned: Promise; + events: string[]; + } { + const captured = capture(); + const cleaned = Promise.withResolvers(); + const events: string[] = []; + const app = new Binden({ + timeout: 30, + access_log: { path: "path" }, + logger: captured.logger, + }).use(async (context: Context) => { + const { request, response } = context; + if (context.url.pathname !== "/next") { + context.setTimeout(0); + await sleep(hold); + await context.text("first"); + return; + } + context.onCleanup(() => { + // Closed by Node.js, or destroyed by the framework + events.push(`cleanup (destroyed: ${String(response.destroyed)})`); + cleaned.resolve(response.writableFinished); + }); + request.once("close", () => { + events.push("request closed"); + }); + for await (const chunk of request) { + events.push(`read ${String(chunk)}`); + } + if (kind === "hangs") { + await never(); + } + response.end("next"); + // Without a connection yet + ok(response.socket === null); + }); + return { app, captured, cleaned: cleaned.promise, events }; + } + + const requests = + "GET / HTTP/1.1\r\nHost: a\r\n\r\n" + + "POST /next HTTP/1.1\r\nHost: a\r\nContent-Length: 4\r\n\r\nbody"; + + /** The `aborted` of the access log of `/next` */ + const nextAborted = (captured: ICaptured): unknown[] => + captured.records + .filter( + ({ msg, path }) => + msg === "The request has completed" && path === "/next", + ) + .map(({ aborted }) => aborted); + + for (const kind of ["complete", "hangs"] as const) { + test(`${kind === "complete" ? "a complete reply left at its deadline" : "the 503 of its deadline"}: finalized once the client goes away`, async () => { + const { app, captured, cleaned, events } = sealed(kind, 5000); + const listening = await listen(app); + try { + const socket = connect(listening.port, "127.0.0.1"); + await once(socket, "connect"); + socket.write(requests); + // Once its deadline has passed + await pollUntil(() => events.length >= 2); + deepStrictEqual(events, ["read body", "request closed"]); + socket.destroy(); + deepStrictEqual(await within(cleaned), false); + await captured.waitFor("The request has completed"); + await sleep(20); + deepStrictEqual(nextAborted(captured), [true]); + // Once + deepStrictEqual(events, [ + "read body", + "request closed", + "cleanup (destroyed: true)", + ]); + } finally { + await listening.close(); + } + }); + + test(`${kind === "complete" ? "a complete reply left at its deadline" : "the 503 of its deadline"}: finalized by a shutdown that times out`, async () => { + const { app, cleaned, events } = sealed(kind, 5000); + const listening = await listen(app); + const socket = connect(listening.port, "127.0.0.1"); + try { + await once(socket, "connect"); + socket.on("error", () => null); + socket.write(requests); + await sleep(80); + const closing = shutdown(listening.server, { + timeout: 50, + cleanup_timeout: 200, + }).then( + () => null, + (error: unknown) => (error as Error).message, + ); + deepStrictEqual(await within(closing), null); + deepStrictEqual(await within(cleaned), false); + deepStrictEqual(events, [ + "read body", + "request closed", + "cleanup (destroyed: true)", + ]); + } finally { + socket.destroy(); + } + }); + } + + test("is not finalized by the close of its request (its body read): it is sent once that response is", async () => { + const { app, cleaned, events } = sealed("complete", 150); + const listening = await listen(app); + try { + const responses = await converse( + listening.port, + requests.replace( + "POST /next HTTP/1.1\r\n", + "POST /next HTTP/1.1\r\nConnection: close\r\n", + ), + ); + deepStrictEqual( + responses.map(({ head, body }) => [head.split("\r\n")[0], body]), + [ + ["HTTP/1.1 200 OK", "first"], + ["HTTP/1.1 200 OK", "next"], + ], + ); + deepStrictEqual(await within(cleaned), true); + deepStrictEqual(events, [ + "read body", + "request closed", + "cleanup (destroyed: true)", + ]); + } finally { + await listening.close(); + } + }); +}); diff --git a/packages/binden/test/decisions-before-guards.test.ts b/packages/binden/test/decisions-before-guards.test.ts new file mode 100644 index 00000000..18dc243b --- /dev/null +++ b/packages/binden/test/decisions-before-guards.test.ts @@ -0,0 +1,299 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { capture, ioTurn, listen, send } from "./helpers.js"; +import { deepStrictEqual, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +/** The messages that tell what code of a source has thrown, or a late error of it */ +const messages = new Set([ + "The release of a stream has failed", + releasedFailure, +]); + +/** A stream-like source of its own, which records the arguments of each destroy */ +class Source extends EventEmitter { + public readonly destroys: unknown[][] = []; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(...args: unknown[]): this { + this.destroys.push(args); + return this; + } +} + +/** A source whose listener of `newListener` runs `act` once the `nth` listener of `event` is added */ +function sourceOn( + event: string, + nth: number, + act: (source: Source) => void, +): Source { + const source = new Source(); + let added = 0; + source.on("newListener", (name: string) => { + if (name === event) { + added += 1; + if (added === nth) { + act(source); + } + } + }); + return source; +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +/** The values that a log (or a report) has been given */ +function recorder(): [unknown[], (error: unknown) => void] { + const recorded: unknown[] = []; + return [ + recorded, + (error): void => { + recorded.push(error); + }, + ]; +} + +/** The reason of a cancellation, which no test expects */ +function cancelled(): Error { + return new Error("Cancelled"); +} + +/** + * Runs `GET /` on an application whose middleware sends a source (then + * replies `500` once the send has rejected): the status, how the send has + * settled, and the records of what code of the source has thrown, or of a + * late error of it + */ +async function run( + sent: (context: Context) => Promise, +): Promise<[number, unknown[], string[]]> { + const captured = capture(); + const outcomes: unknown[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + outcomes.push( + await sent(context).then( + () => "resolved", + (error: unknown) => error, + ), + ); + await context.response.status(500).send("failed"); + }); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + await ioTurn(); + const logs = captured.records + .filter(({ msg }) => messages.has(msg as string)) + .map( + ({ msg, failure }) => + `${String(msg)}: ${String((failure as { message?: unknown }).message)}`, + ); + return [status, outcomes, logs]; + } finally { + await listening.close(); + } +} + +suite( + "A stream-like source that fails while it is sent (its `error`), the error that decides the send before its guard is installed", + () => { + for (const [event, nth] of [ + ["close", 2], + ["error", 2], + ] as const) { + test(`whose code throws it again for the listener of \`${event}\` of its guard: rejects with it, which is not logged (R21-C2)`, async () => { + const failure = new Error("The source has failed"); + // The second one is the one of its guard (after the one of the send) + const source = sourceOn(event, nth, () => { + throw failure; + }); + const [status, outcomes, logs] = await run(async (context) => { + const sent = context.send(source); + source.emit("error", failure); + await sent; + }); + deepStrictEqual([status, outcomes, logs], [500, [failure], []]); + }); + } + }, +); + +suite( + "A stream-like source destroyed with an error before its send (its `errored`), that error decided before its guard is installed", + () => { + test("whose code throws it again for the listener of `close` of its guard: rejects with it, which is not logged (R21-C2)", async () => { + const failure = new Error("Destroyed with it"); + // The first one is the one of its guard: nothing else listens + const source = Object.assign( + sourceOn("close", 1, () => { + throw failure; + }), + { destroyed: true, errored: failure }, + ); + const [status, outcomes, logs] = await run((context) => + context.send(source), + ); + deepStrictEqual([status, outcomes, logs], [500, [failure], []]); + }); + + test("whose code emits it while its guard is installed: rejects with it, which is not logged (R1 F1)", async () => { + const failure = new Error("Destroyed with it"); + const source = Object.assign( + sourceOn("close", 1, (emitter) => { + emitter.emit("error", failure); + }), + { destroyed: true, errored: failure }, + ); + const [status, outcomes, logs] = await run((context) => + context.send(source), + ); + deepStrictEqual([status, outcomes, logs], [500, [failure], []]); + }); + + test("whose own `on()` gives each new listener of its `error` that error at once (a sticky error): rejects with it, which is not logged (R1 F1)", async () => { + const failure = new Error("Sticky"); + const source = Object.assign(new Source(), { + destroyed: true, + errored: failure, + }); + Object.assign(source, { + on(event: string, listener: (error: unknown) => void): Source { + EventEmitter.prototype.on.call(source, event, listener); + if (event === "error") { + listener(failure); + } + return source; + }, + }); + const [status, outcomes, logs] = await run((context) => + context.send(source), + ); + deepStrictEqual([status, outcomes, logs], [500, [failure], []]); + }); + + test("of `readable-stream` 4, which emits it while its guard is installed: rejects with it, which is not logged (R1 F1)", async () => { + const failure = new Error("Destroyed with it"); + const { Readable } = require("readable-stream-v4") as { + Readable: new (options: { read(): void }) => EventEmitter & { + destroy(error: Error): unknown; + }; + }; + const source = new Readable({ + read(): void { + // Never read + }, + }); + // The application listens + source.on("error", () => null); + source.destroy(failure); + let armed = true; + source.on("newListener", (event: string) => { + if (armed && event === "close") { + armed = false; + source.emit("error", failure); + } + }); + const [status, outcomes, logs] = await run((context) => + context.send(source as Parameters[0]), + ); + deepStrictEqual([status, outcomes, logs], [500, [failure], []]); + }); + + test("whose `errored` cannot be read (a getter of its own that throws): the send fails with what it throws, and its guard listens all the same", () => { + const thrown = new Error("No errored"); + const source = Object.assign(new Source(), { destroyed: true }); + Object.defineProperty(source, "errored", { + get(): never { + throw thrown; + }, + }); + throws( + () => readStreamLike(source, methodsOf(source), cancelled, () => null), + thrown, + ); + deepStrictEqual(source.listenerCount("error"), 1); + }); + }, +); + +suite( + "A readable of a stream-like source destroyed with an error, which decides its send before the guard of the source is installed", + () => { + for (const [event, destroyed, how] of [ + ["close", true, "with it"], + // Its guard is refused: only the listener of the send hears it + ["error", false, "without it"], + ] as const) { + test(`whose code throws it again for the listener of \`${event}\` of its guard: not logged, and the source is destroyed ${how}`, () => { + const failure = new Error("Decided by the send"); + // The second one is the one of its guard (after the one of the send) + const source = sourceOn(event, 2, () => { + throw failure; + }); + const [released, log] = recorder(); + const [reports, report] = recorder(); + const readable = readStreamLike( + source, + methodsOf(source), + cancelled, + report, + () => null, + { released: log, removed: log }, + ); + readable.on("error", () => null).destroy(failure); + deepStrictEqual( + [released, reports, source.destroys], + [[], [], [destroyed ? [failure] : []]], + ); + }); + } + + test("by a `pause()` that throws once it is full, then thrown again for the listener of `close` of its guard: not logged", () => { + const failure = new Error("No pause"); + const source = sourceOn("close", 2, () => { + throw failure; + }); + Object.assign(source, { + pause(): never { + throw failure; + }, + }); + const [released, log] = recorder(); + const readable = readStreamLike( + source, + methodsOf(source), + cancelled, + () => null, + () => null, + { released: log, removed: log }, + ).on("error", () => null); + // More than its high water mark: it is full, and paused + source.emit("data", Buffer.alloc(readable.readableHighWaterMark)); + deepStrictEqual( + [readable.errored, released, source.destroys], + [failure, [], [[failure]]], + ); + }); + }, +); diff --git a/packages/binden/test/deferred-reports.test.ts b/packages/binden/test/deferred-reports.test.ts new file mode 100644 index 00000000..afbf76c0 --- /dev/null +++ b/packages/binden/test/deferred-reports.test.ts @@ -0,0 +1,543 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { EventEmitter, once } from "node:events"; +import { HOST, type ICaptured, capture, ioTurn, listen } from "./helpers.js"; +import { PassThrough, Readable } from "node:stream"; +import { + decidedFor, + emitterMethods, + forwarder, + guard, + hear, + reportLate, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { connect } from "node:net"; +import { createRequire } from "node:module"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const require = createRequire(import.meta.url); + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, without a handler + * of the process: a crash (e.g. an exception thrown out of a turn) is its exit + * code and output, not a failure of the test runner + */ +function fixture(name: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 40_000 }, + ); + return { status, stdout, stderr }; +} + +/** The values that a report of a late error has been given */ +function reporter(): [unknown[], (error: unknown) => void] { + const reported: unknown[] = []; + return [ + reported, + (error): void => { + reported.push(error); + }, + ]; +} + +/** The message of each late error logged so far */ +function lateOf(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +/** How a send has settled: `resolved`, or the message of what it has rejected with */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => `rejected ${(error as Error).message}`, + ); +} + +/** A stream-like source of its own */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } +} + +/** A client connection to `HOST` (see `request()`) */ +interface IClient { + /** Goes away */ + destroy(): void; +} + +/** Writes a `GET` of the path on a connection of its own, which stays open */ +async function request(port: number, path: string): Promise { + const socket = connect(port, HOST); + socket.resume().on("error", () => { + // Its close follows + }); + await once(socket, "connect"); + socket.write(`GET ${path} HTTP/1.1\r\nHost: a\r\n\r\n`); + return { + destroy: (): void => { + socket.destroy(); + }, + }; +} + +/** Resolves on the turn after `ms` milliseconds */ +function later(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +suite("A late error is reported on a later turn", () => { + test("never while the event that brings it runs, by its guard, the listener that a settled send keeps, or its send", async () => { + const [reports, report] = reporter(); + const guarded = new EventEmitter(); + guard(guarded, emitterMethods, report); + guarded.emit("error", new Error("guard")); + const kept = new EventEmitter(); + const listener = forwarder(() => null); + kept.on("error", listener.listener); + listener.detach(kept, report); + kept.emit("error", new Error("forwarder")); + reportLate(new EventEmitter(), new Error("reportLate"), report); + const before = [...reports]; + await ioTurn(); + deepStrictEqual( + [before, reports.map((error) => (error as Error).message)], + [[], ["guard", "forwarder", "reportLate"]], + ); + }); + + for (const first of ["the guard", "the deciding send"] as const) { + test(`by its guard, not once a send that hears it in the same event decides it (${first} listening first); another error, once`, async () => { + const [reports, report] = reporter(); + const source = new EventEmitter(); + const failure = new Error("Decided by a send"); + // A live send of the source, which fails with its error, and decides + // it for that source + const deciding = (error: unknown): void => { + if (error === failure) { + decidedFor(source, error); + } + }; + if (first === "the guard") { + guard(source, emitterMethods, report); + source.on("error", deciding); + } else { + source.on("error", deciding); + guard(source, emitterMethods, report); + } + source.emit("error", failure); + const another = new Error("Not decided"); + source.emit("error", another); + source.emit("error", another); + await ioTurn(); + deepStrictEqual(reports, [another]); + }); + } + + test("by its guard, not once a send decides it on the next tick (e.g. the readable of a stream-like source that reaches its send then)", async () => { + const [reports, report] = reporter(); + const source = new EventEmitter(); + const failure = new Error("Decided on the next tick"); + guard(source, emitterMethods, report); + source.emit("error", failure); + process.nextTick(() => { + decidedFor(source, failure); + }); + await ioTurn(); + deepStrictEqual(reports, []); + }); + + for (const first of ["the kept listener", "the live send"] as const) { + test(`by the listener that a settled send keeps, not while another send hears it and decides it in the same event (${first} listening first)`, async () => { + const [reports, report] = reporter(); + const source = new EventEmitter(); + const failure = new Error("Decided by the live send"); + const kept = forwarder(() => null); + const live = (error: unknown): void => { + decidedFor(source, error); + }; + if (first === "the kept listener") { + source.on("error", kept.listener).on("error", live); + } else { + source.on("error", live).on("error", kept.listener); + } + // The live send hears the source (see `hear()`), whose guard has + // refused (none listens) + hear(source, 1); + kept.detach(source, report); + source.emit("error", failure); + await ioTurn(); + // No longer heard: a later error is reported by the kept listener + hear(source, -1); + source.off("error", live); + const another = new Error("Later"); + source.emit("error", another); + await ioTurn(); + deepStrictEqual(reports, [another]); + }); + } + + test("by its send (reportLate()), not once a send of the source decides it later in the turn; another error, once", async () => { + const [reports, report] = reporter(); + const source = new EventEmitter(); + const failure = new Error("Decided later in the turn"); + reportLate(source, failure, report); + decidedFor(source, failure); + const another = new Error("Not decided"); + reportLate(source, another, report); + reportLate(source, another, report); + await ioTurn(); + deepStrictEqual(reports, [another]); + }); + + test("a report that throws on that turn (e.g. a logger whose own debug() throws) is ignored, whatever reports it: the process does not crash", () => { + deepStrictEqual(fixture("throwing-late-reports.ts"), { + status: 0, + stdout: "200 guard,forwarder,reportLate,framework\n", + stderr: "", + }); + }); +}); + +/** A stream of `readable-stream` */ +interface IStream extends EventEmitter { + push(chunk: unknown): boolean; + destroy(error?: Error): unknown; +} + +/** A `Readable` of a version of `readable-stream` */ +function readableOf(version: string): IStream { + const { Readable: Constructor } = require(`readable-stream-v${version}`) as { + Readable: new (options: { read(): void }) => IStream; + }; + return new Constructor({ + read(): void { + // Pushed by the test + }, + }); +} + +/** A source of its own whose `push()` emits its `data` */ +class Pushing extends Source { + public push(chunk: string): boolean { + this.emit("data", Buffer.from(chunk)); + return true; + } + + public destroy(): this { + return this; + } +} + +/** A source of its own whose `destroy()` emits its last error again, at once */ +class Reemitting extends Pushing { + public destroyed = false; + public last: Error | null = null; + + public override destroy(): this { + this.destroyed = true; + if (this.last !== null) { + this.emit("error", this.last); + } + return this; + } +} + +suite( + "A stream-like source that emits the error that decides its send again before the send has recorded it", + () => { + const failure = new Error("The source has failed"); + const another = new Error("A later error"); + /** The cases: the source, how it fails twice, and its later error */ + const cases: readonly (readonly [ + string, + () => IStream, + (source: IStream) => void, + ])[] = [ + [ + "an emitter of its own that emits it twice", + (): IStream => new Pushing(), + (source): void => { + source.emit("error", failure); + source.emit("error", failure); + source.emit("error", another); + }, + ], + [ + "readable-stream 2 destroyed twice with it", + (): IStream => readableOf("2"), + (source): void => { + source.destroy(failure); + source.destroy(failure); + setTimeout(() => source.emit("error", another), 10); + }, + ], + [ + "readable-stream 3 destroyed twice with it", + (): IStream => readableOf("3"), + (source): void => { + source.destroy(failure); + source.destroy(failure); + setTimeout(() => source.emit("error", another), 10); + }, + ], + [ + "readable-stream 4 that emits it twice", + (): IStream => readableOf("4"), + (source): void => { + source.emit("error", failure); + source.emit("error", failure); + source.emit("error", another); + }, + ], + [ + "an emitter whose destroy() emits it again once the send releases it (R22-C1)", + (): IStream => new Reemitting(), + (source): void => { + (source as unknown as Reemitting).last = failure; + source.emit("error", failure); + setTimeout(() => source.emit("error", another), 10); + }, + ], + ]; + for (const [name, create, fail] of cases) { + test(`${name}: the send rejects with it, which is not logged; a later error is logged once`, async () => { + const captured = capture(); + const outcomes: string[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const source = create(); + setTimeout(() => { + source.push("abc"); + setTimeout(() => { + fail(source); + }, 5); + }, 5); + // @ts-expect-error -- a stream-like source + outcomes.push(await outcomeOf(context.send(source))); + }); + const listening = await listen(app); + try { + const client = await request(listening.port, "/"); + for (let wait = 0; wait < 200 && outcomes.length === 0; wait += 1) { + await later(5); + } + await later(20); + await ioTurn(); + client.destroy(); + deepStrictEqual( + [outcomes, lateOf(captured)], + [[`rejected ${failure.message}`], [another.message]], + ); + } finally { + await listening.close(); + } + }); + } + }, +); + +suite( + "A shared source whose late error rejects a live send of another response", + () => { + for (const how of ["refuses the guard", "throws on a removal"] as const) { + for (const order of ["settled first", "live first"] as const) { + test(`kept by a settled send whose source ${how} (the ${order}): not logged; a later error is logged once (R22-4)`, async () => { + const captured = capture(); + const failure = new Error("Its destroy has failed later"); + const shared = new Readable({ + read(): void { + // Pushed by the test + }, + destroy(_error, callback): void { + setTimeout(() => { + callback(failure); + }, 5); + }, + }); + shared.push("x"); + let armed = false; + if (how === "refuses the guard") { + shared.on("newListener", (event: string) => { + if (armed && event === "error") { + armed = false; + throw new Error("Refused"); + } + }); + } else { + const remove = shared.removeListener.bind(shared); + const removing = ( + event: string, + listener: (...args: unknown[]) => void, + ): Readable => { + if (armed && event === "error") { + armed = false; + throw new Error("Removal failed"); + } + return remove(event, listener); + }; + Object.assign(shared, { removeListener: removing, off: removing }); + } + const outcomes = new Map(); + const started = new Map void>(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const who = context.url.pathname; + const sent = outcomeOf(context.send(shared)); + started.get(who)?.(); + outcomes.set(who, await sent); + if (!context.response.writableEnded) { + context.response.end("done"); + } + }); + const listening = await listen(app); + try { + const begin = async (path: string): Promise => { + const { promise, resolve } = Promise.withResolvers(); + started.set(path, () => { + resolve(null); + }); + const client = await request(listening.port, path); + await promise; + return client; + }; + // The settled send listens first, or after the live one + let [settling, live]: (IClient | null)[] = [null, null]; + if (order === "settled first") { + settling = await begin("/settled"); + live = await begin("/live"); + } else { + live = await begin("/live"); + settling = await begin("/settled"); + } + await later(10); + armed = true; + // Its client goes away: its send settles, keeps its listener + // of `error`, and the source is destroyed + settling.destroy(); + for (let wait = 0; wait < 200 && outcomes.size < 2; wait += 1) { + await later(5); + } + await ioTurn(); + const decided = lateOf(captured); + shared.emit("error", new Error("Later")); + await ioTurn(); + live.destroy(); + deepStrictEqual( + [ + outcomes.get("/live"), + decided, + lateOf(captured), + shared.listenerCount("error") > 0, + ], + [`rejected ${failure.message}`, [], ["Later"], true], + ); + } finally { + await listening.close(); + } + }); + } + } + }, +); + +suite( + "A shared source destroyed with an error, whose send ends its own response while it reads it, as another response sends it", + () => { + for (const version of ["Node.js", "readable-stream 4"] as const) { + test(`(${version}): that error rejects the live send, and is not logged (R22-3)`, async () => { + const captured = capture(); + const failure = new Error("Shared"); + let ending: (() => void) | null = null; + const { PassThrough: Constructor } = ( + version === "Node.js" + ? { PassThrough } + : require("readable-stream-v4") + ) as { PassThrough: new () => PassThrough }; + // Its `errored`, once read, ends the response of the second send + const shared = new Constructor(); + Object.defineProperty(shared, "errored", { + get(): unknown { + ending?.(); + ending = null; + return Reflect.get( + Object.getPrototypeOf(shared) as object, + "errored", + shared, + ); + }, + }); + const outcomes = new Map(); + const contexts = new Map(); + const ready = Promise.withResolvers(); + const live = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const who = context.url.pathname; + if (who === "/ending") { + contexts.set(who, context); + ready.resolve(null); + await later(100); + return; + } + const sent = outcomeOf(context.send(shared)); + live.resolve(null); + outcomes.set(who, await sent); + if (!context.response.writableEnded) { + context.response.status(500).end("failed"); + } + }); + const listening = await listen(app); + try { + const clients = [ + await request(listening.port, "/live"), + await request(listening.port, "/ending"), + ]; + await Promise.all([ready.promise, live.promise]); + const context = contexts.get("/ending") as Context; + shared.destroy(failure); + ending = (): void => { + context.response.end("ended"); + }; + outcomes.set("/ending", await outcomeOf(context.send(shared))); + for (let wait = 0; wait < 200 && outcomes.size < 2; wait += 1) { + await later(5); + } + await ioTurn(); + for (const client of clients) { + client.destroy(); + } + deepStrictEqual( + [outcomes.get("/live"), outcomes.get("/ending"), lateOf(captured)], + [`rejected ${failure.message}`, "resolved", []], + ); + } finally { + await listening.close(); + } + }); + } + }, +); diff --git a/packages/binden/test/described-refusals.test.ts b/packages/binden/test/described-refusals.test.ts new file mode 100644 index 00000000..9c34c961 --- /dev/null +++ b/packages/binden/test/described-refusals.test.ts @@ -0,0 +1,460 @@ +import { Binden, type BindenResponse, type Context, Cookie } from "../index.js"; +import { listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** A value of any type, given in place of the one that a method declares */ +function given(value: unknown): T { + return value as T; +} + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** A class, whose constructor cannot be called as a function */ +class Stringify { + public readonly created = Date.now(); +} + +/** A proxy that has been revoked: any operation on it throws */ +function revoked(target: T): T { + const { proxy, revoke } = Proxy.revocable(target, {}); + revoke(); + return proxy; +} + +/** + * An object that counts its conversions (`toString()`, `valueOf()` and + * `Symbol.toPrimitive`), and its count: a refusal that describes it must not + * convert it + */ +function counting(): [object, () => number] { + let conversions = 0; + const convert = (): string => { + conversions += 1; + return "200"; + }; + return [ + { toString: convert, valueOf: convert, [Symbol.toPrimitive]: convert }, + (): number => conversions, + ]; +} + +/** What a call has settled with, and the response once it has */ +interface ISettled { + /** + * `resolved`, `returned`, or the class and the message of its error, and + * whether its stack was formatted once the caller got it (an error that + * is not keeps the objects of its frames, e.g. the response) + */ + readonly outcome: unknown; + /** + * Whether the headers were sent, the status, and the `Content-Type`, + * `Vary`, `Location` and `Set-Cookie` headers + */ + readonly state: unknown; +} + +/** + * Calls a method of the response once for a request, with arguments that + * JavaScript may give in place of the declared ones, while + * `Error.prepareStackTrace` records each error whose stack V8 formats: what + * it has settled with, and the reply that the client gets once the + * middleware has replied `200 replied` itself + */ +async function calling( + call: (response: BindenResponse) => unknown, +): Promise { + const settled = Promise.withResolvers(); + const app = new Binden().use(async ({ response }: Context) => { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + formatted.add(error); + return [`${error.name}: ${error.message}`, ...frames.map(String)].join( + "\n at ", + ); + }, + ); + let outcome: unknown = "returned"; + try { + const result = call(response); + if (result instanceof Promise) { + await result; + outcome = "resolved"; + } + } catch (error: unknown) { + const { constructor, message } = error as Error; + outcome = [constructor.name, message, formatted.has(error as Error)]; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + settled.resolve({ + outcome, + state: [ + response.headersSent, + response.statusCode, + ...["Content-Type", "Vary", "Location", "Set-Cookie"].map( + (name) => response.getHeader(name) ?? null, + ), + ], + }); + if (!response.headersSent) { + await response.status(200).send("replied"); + } + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + return { ...(await within(settled.promise)), reply: [status, body] }; + } finally { + await listening.close(); + } +} + +/** What a refusal leaves: nothing of the response changed, and its reply */ +const unchanged = { + state: [false, 200, null, null, null, null], + reply: [200, "replied"], +}; + +/** A `TypeError` of binden, formatted once the caller gets it */ +function refused(message: string): ISettled & { reply: unknown } { + return { outcome: ["TypeError", message, true], ...unchanged }; +} + +/** + * The values that a refusal describes without converting them (see + * `described()`), with their description + */ +const values: readonly (readonly [unknown, string])[] = [ + ["x", "x"], + [99, "99"], + [true, "true"], + [200n, "200n"], + [null, "null"], + [none, "undefined"], + [Symbol("value"), "(a symbol)"], + [(): number => 200, "(a function)"], + [Object.create(null), "(an object)"], + [revoked({}), "(an object)"], +]; + +suite( + "The argument errors of the methods of binden describe the value that they refuse without converting it, formatted (R26-3)", + () => { + test("status(): every type", async () => { + for (const [value, description] of values) { + deepStrictEqual( + await calling((response) => response.status(given(value))), + refused(`Status code ${description} is invalid`), + description, + ); + } + const [value, conversions] = counting(); + deepStrictEqual( + await calling((response) => response.status(given(value))), + refused("Status code (an object) is invalid"), + ); + deepStrictEqual(conversions(), 0); + }); + + test("redirect() with a status that is not a redirect", async () => { + const [value, conversions] = counting(); + for (const [status, description] of [ + [Symbol("status"), "(a symbol)"], + [Object.create(null), "(an object)"], + [value, "(an object)"], + ["302", "302"], + ] as const) { + deepStrictEqual( + await calling((response) => response.redirect("/", given(status))), + refused(`Status code ${description} is not a redirect`), + description, + ); + } + deepStrictEqual(conversions(), 0); + }); + + test("type()", async () => { + const [value, conversions] = counting(); + for (const [type, description] of [ + [Symbol("type"), "(a symbol)"], + [Object.create(null), "(an object)"], + [value, "(an object)"], + [null, "null"], + ] as const) { + deepStrictEqual( + await calling((response) => response.type(given(type))), + refused(`Invalid media type: ${description}`), + description, + ); + } + deepStrictEqual(conversions(), 0); + }); + + test("vary(): a string before the regular expression, which would convert it (`null` and `5` would match it)", async () => { + const [value, conversions] = counting(); + for (const [field, description] of [ + [Symbol("field"), "(a symbol)"], + [Object.create(null), "(an object)"], + [value, "(an object)"], + [null, "null"], + [5, "5"], + ] as const) { + deepStrictEqual( + await calling((response) => + response.vary("Accept", given(field)), + ), + refused(`Invalid field name: ${description}`), + description, + ); + } + deepStrictEqual(conversions(), 0); + }); + + test("set(): anything but an object, before its entries are read (a string would set a header of each character)", async () => { + for (const [headers, label] of [ + [null, "null"], + [5, "a number"], + ["ab", "a string"], + [Symbol("headers"), "a symbol"], + [(): null => null, "a function"], + ] as const) { + deepStrictEqual( + await calling((response) => response.set(given(headers))), + refused("`headers` is not an object"), + label, + ); + } + }); + + test("the encoding of send(), write() and end()", async () => { + const [value, conversions] = counting(); + for (const [encoding, description] of [ + [Symbol("encoding"), "(a symbol)"], + [Object.create(null), "(an object)"], + [value, "(an object)"], + ] as const) { + deepStrictEqual( + await calling((response) => response.send("data", given(encoding))), + refused(`Unknown encoding: ${description}`), + `send() ${description}`, + ); + for (const method of ["write", "end"] as const) { + deepStrictEqual( + await calling((response) => { + try { + return response[method]("data", given(encoding)); + } catch (error: unknown) { + // Its code, as Node.js gives it + deepStrictEqual( + (error as { code?: unknown }).code, + "ERR_UNKNOWN_ENCODING", + ); + throw error; + } + }), + refused(`Unknown encoding: ${description}`), + `${method}() ${description}`, + ); + } + } + deepStrictEqual(conversions(), 0); + }); + + test("clearCookie() with a SameSite that is not one", async () => { + const [value, conversions] = counting(); + for (const [same_site, description] of [ + [Symbol("same_site"), "(a symbol)"], + [Object.create(null), "(an object)"], + [value, "(an object)"], + ["Loose", "Loose"], + ] as const) { + deepStrictEqual( + await calling((response) => + response.clearCookie("a", { same_site: given(same_site) }), + ), + refused(`Invalid cookie SameSite: ${description}`), + description, + ); + } + deepStrictEqual(conversions(), 0); + }); + + test("a Cookie with a Max-Age that is not an integer", () => { + const [value, conversions] = counting(); + for (const [max_age, description] of [ + [Symbol("max_age"), "(a symbol)"], + [Object.create(null), "(an object)"], + [value, "(an object)"], + [1.5, "1.5"], + ["5", "5"], + [5n, "5n"], + ] as const) { + let outcome: unknown = "constructed"; + try { + // eslint-disable-next-line no-new -- refused by its constructor + new Cookie({ key: "a", value: "b", max_age: given(max_age) }); + } catch (error: unknown) { + const { constructor, message } = error as Error; + outcome = [constructor.name, message]; + } + deepStrictEqual( + outcome, + ["TypeError", `Invalid cookie Max-Age: ${description}`], + description, + ); + } + deepStrictEqual(conversions(), 0); + }); + }, +); + +suite( + "What reading or calling an argument of a method of the response throws is formatted, also an error that V8 creates while the frames of the response are on the stack (R26-3)", + () => { + test("json() with a stringify that cannot be called (a class constructor, a revoked proxy), and one that throws", async () => { + for (const [stringify, label] of [ + [Stringify, "a class constructor"], + [revoked(() => "{}"), "a revoked proxy"], + ] as const) { + const { outcome, ...rest } = await calling((response) => + response.json({}, given(stringify)), + ); + // The message of V8, which differs between its versions + const [name, , formatted] = outcome as unknown[]; + deepStrictEqual( + { outcome: [name, formatted], ...rest }, + { outcome: ["TypeError", true], ...unchanged }, + label, + ); + } + // An error of the application's own: the same error, formatted + const own = new RangeError("own"); + deepStrictEqual( + await calling(async (response) => { + try { + await response.json({}, () => { + throw own; + }); + } catch (error: unknown) { + deepStrictEqual(error, own); + throw error; + } + }), + { outcome: ["RangeError", "own", true], ...unchanged }, + ); + }); + + test("a revoked proxy given to set(), clearCookie(), send(), redirect() (as its location or its options), and the Expires of a cookie that is a proxy of a Date", async () => { + for (const [call, label] of [ + [ + (response: BindenResponse): unknown => response.set(revoked({})), + "set()", + ], + [ + (response: BindenResponse): unknown => + response.clearCookie("a", revoked({})), + "clearCookie()", + ], + [ + (response: BindenResponse): unknown => + response.clearCookie( + "a", + given({ expires: new Proxy(new Date(0), {}) }), + ), + "the Expires of clearCookie()", + ], + [ + (response: BindenResponse): unknown => + response.send(given(revoked({}))), + "send()", + ], + [ + (response: BindenResponse): unknown => + response.redirect(revoked(new URL("http://a/"))), + "the location of redirect()", + ], + [ + (response: BindenResponse): unknown => + response.redirect("/", 302, revoked({})), + "the options of redirect()", + ], + ] as const) { + const { outcome, ...rest } = await calling(call); + // The message of V8, which differs between its versions + const [name, , formatted] = outcome as unknown[]; + deepStrictEqual( + { outcome: [name, formatted], ...rest }, + { outcome: ["TypeError", true], ...unchanged }, + label, + ); + } + }); + + test("redirect() still reads an inherited allow_external, as destructuring does", async () => { + deepStrictEqual( + await calling((response) => + response.redirect( + "https://other.example/", + 302, + Object.create({ allow_external: true }) as object, + ), + ), + { + outcome: "resolved", + state: [true, 302, null, null, "https://other.example/", null], + reply: [302, ""], + }, + ); + }); + }, +); + +suite( + "A refusal of a value that binden describes without converting it, or whose reading throws, kept by the application without its stack read, keeps neither the request nor the response (R26-3)", + () => { + for (const mode of [ + "statusSymbol", + "statusNullProto", + "redirectStatusNullProto", + "typeSymbol", + "varySymbol", + "varyNull", + "setNull", + "sendEncodingNullProto", + "writeEncodingNullProto", + "clearSameSiteSymbol", + "clearSameSiteNull", + "clearExpiresProxy", + "jsonClass", + "jsonRevoked", + ]) { + test(mode, () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "created-error-retention.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + // 60 requests, whose errors are kept + deepStrictEqual( + { status, stdout, stderr }, + { status: 0, stdout: "collected 60 TypeError\n", stderr: "" }, + ); + }); + } + }, +); diff --git a/packages/binden/test/destroy-values.test.ts b/packages/binden/test/destroy-values.test.ts new file mode 100644 index 00000000..1a5a8a41 --- /dev/null +++ b/packages/binden/test/destroy-values.test.ts @@ -0,0 +1,455 @@ +import { Binden, type Context } from "../index.js"; +import { + type ICaptured, + capture, + ioTurn, + listen, + send, + within, +} from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { createReadStream } from "node:fs"; +import { deepStrictEqual } from "node:assert/strict"; + +/** The message of the failure of a release of a source */ +const releaseFailure = "The release of a stream has failed"; + +/** The message of the error that carries a value that Node.js cannot read safely */ +const carried = "A stream has failed"; + +/** + * Replies one request with the middleware, which sends the source: resolves + * with the status that the client gets, the outcome of the send (`resolved`, + * or the value that it has rejected with), and its logger, once the + * middleware has settled + */ +async function replied( + source: Readable, + details = true, +): Promise<[number, unknown, ICaptured]> { + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: details, + }).use(async (context: Context) => { + await context.send(source).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve(error); + throw error; + }, + ); + }); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + return [status, await within(outcome.promise), captured]; + } finally { + await listening.close(); + } +} + +/** The messages of the failures of a release that are logged */ +function releases(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === releaseFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +/** + * Runs `run` while `Error.prepareStackTrace` throws for the errors that + * `fails` tells (as a hook of the application may), and formats the others + */ +async function failing( + fails: (error: Error) => boolean, + run: () => Promise, +): Promise { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + if (fails(error)) { + throw new Error("The hook has failed"); + } + return [ + `${error.name}: ${error.message}`, + ...frames.map((frame) => ` at ${String(frame)}`), + ].join("\n"); + }, + ); + try { + return await run(); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A value that the source throws, and how many times its `stack` is read */ +interface IThrown { + readonly value: unknown; + reads(): number; +} + +/** A value that the source throws, whose `stack` is not read */ +function plain(value: unknown): IThrown { + return { value, reads: () => 0 }; +} + +/** + * An error whose `stack` is a getter of the application, which counts its + * reads: it returns a string first, and throws then (e.g. on the second read, + * by Node.js once binden would have read it) + */ +function twoFaced(): IThrown { + let reads = 0; + const value = Object.defineProperty(new Error("Two-faced"), "stack", { + configurable: true, + get(): string { + reads += 1; + if (reads > 1) { + throw new Error("Its stack cannot be read again"); + } + return "Error: Two-faced"; + }, + }); + return { value, reads: () => reads }; +} + +/** An object whose `stack` is a getter of its prototype, of the application */ +function inherited(): IThrown { + let reads = 0; + const prototype = Object.defineProperty({}, "stack", { + get(): never { + reads += 1; + throw new Error("Its stack cannot be read"); + }, + }); + const value: unknown = Object.create(prototype); + return { value, reads: () => reads }; +} + +/** A proxy (whose traps are code of the application), which counts its reads of `stack` */ +function proxied(target: object): IThrown { + let reads = 0; + const value = new Proxy(target, { + get(object, key, receiver): unknown { + if (key === "stack") { + reads += 1; + } + return Reflect.get(object, key, receiver) as unknown; + }, + }); + return { value, reads: () => reads }; +} + +/** + * Sends a file whose own `resume()` throws `thrown`: the outcome of the send, + * whether the file is destroyed and closed, its `errored`, the values that + * its `destroy()` is called with, and how many times binden had read the + * `stack` of the value when it called it + */ +async function destroyedBy( + thrown: IThrown, + details = true, +): Promise<{ + status: number; + outcome: unknown; + destroys: unknown[]; + reads: number[]; + destroyed: boolean; + closed: boolean; + errored: unknown; + captured: ICaptured; +}> { + const source: Readable = createReadStream(import.meta.filename); + const destroys: unknown[] = []; + const reads: number[] = []; + source.destroy = function destroy(this: Readable, error?: Error): Readable { + destroys.push(error); + reads.push(thrown.reads()); + return Readable.prototype.destroy.call(this, error); + }; + source.resume = (): never => { + throw thrown.value; + }; + // Not `once()`, which rejects at its `error` + const closed = new Promise((resolve) => { + source.on("close", () => { + resolve(); + }); + }); + const [status, outcome, captured] = await replied(source, details); + await within(closed); + await ioTurn(); + return { + status, + outcome, + destroys, + reads, + destroyed: source.destroyed, + closed: source.closed, + errored: (source as { errored: unknown }).errored, + captured, + }; +} + +suite( + "A Readable that binden destroys with what its code has thrown is destroyed by one call of its `destroy()`", + () => { + for (const [what, make] of [ + [ + "a native error, whose stack is not formatted yet (the getter of the engine, which binden reads first)", + (): IThrown => plain(new Error("Native")), + ], + [ + "an instance of a subclass of `Error` (the getter of the engine)", + (): IThrown => + plain(new (class Subclassed extends Error {})("Subclassed")), + ], + [ + "a `DOMException` (the getter of the engine)", + (): IThrown => plain(new DOMException("Aborted", "AbortError")), + ], + [ + "an error whose `stack` is a value of its own", + (): IThrown => + plain( + Object.defineProperty(new Error("Own"), "stack", { + value: "Error: Own", + }), + ), + ], + [ + "an object without a `stack`", + (): IThrown => plain({ message: "Plain" }), + ], + ["a string", (): IThrown => plain("A string")], + ] as const) { + test(`with the value itself, when Node.js reads its \`stack\` without running code of the application: ${what}`, async () => { + const thrown = make(); + const { + status, + outcome, + destroys, + destroyed, + closed, + errored, + captured, + } = await destroyedBy(thrown); + deepStrictEqual( + [ + status, + outcome === thrown.value, + destroys.length, + destroys[0] === thrown.value, + destroyed, + closed, + errored === thrown.value, + ], + [500, true, 1, true, true, true, true], + ); + deepStrictEqual(releases(captured), []); + }); + } + + test("without an error for a value that Node.js does not read (`null`)", async () => { + const thrown = plain(null); + const { status, outcome, destroys, destroyed, closed, errored } = + await destroyedBy(thrown); + deepStrictEqual( + [status, outcome, destroys, destroyed, closed, errored], + [500, null, [null], true, true, null], + ); + }); + + for (const [what, make] of [ + [ + "a getter of its own that returns a string on its first read and throws on the second, which binden never calls", + twoFaced, + ], + [ + "a getter of a prototype of the application, which binden never calls", + inherited, + ], + [ + "a proxy of an error, whose traps binden never runs", + (): IThrown => proxied(new Error("Proxied")), + ], + [ + "an object whose prototype is a proxy", + (): IThrown => plain(Object.create(new Proxy({}, {})) as object), + ], + ] as const) { + test(`with an \`Error\` that carries the value as its \`cause\` otherwise (its send still rejects with the value, nothing is logged): ${what}`, async () => { + const thrown = make(); + const { + status, + outcome, + destroys, + reads, + destroyed, + closed, + errored, + captured, + } = await destroyedBy(thrown); + const error = errored as Error | null; + deepStrictEqual( + [ + status, + outcome === thrown.value, + destroys.length, + destroys[0] === errored, + reads, + destroyed, + closed, + ], + [500, true, 1, true, [0], true, true], + ); + deepStrictEqual( + [error?.constructor, error?.message, error?.cause === thrown.value], + [Error, carried, true], + ); + deepStrictEqual(releases(captured), []); + }); + } + + test("with an `Error` that carries a native error whose stack a `prepareStackTrace` of the application fails to format", async () => { + const thrown = plain(new Error("Unformattable")); + const { outcome, destroys, destroyed, closed, errored } = await failing( + (error) => error === thrown.value, + // Without details: the logger would read its stack + () => destroyedBy(thrown, false), + ); + const error = errored as Error | null; + deepStrictEqual( + [ + outcome === thrown.value, + destroys.length, + destroyed, + closed, + error?.message, + error?.cause === thrown.value, + ], + [true, 1, true, true, carried, true], + ); + }); + + test("whose stack is its header when that hook fails to format it as well", async () => { + const thrown = plain(new Error("Unformattable")); + const { outcome, destroys, destroyed, closed, errored } = await failing( + (error) => error === thrown.value || error.cause === thrown.value, + // Without details: the logger would read its stack + () => destroyedBy(thrown, false), + ); + const error = errored as Error | null; + deepStrictEqual( + [ + outcome === thrown.value, + destroys.length, + destroyed, + closed, + error?.cause === thrown.value, + error?.stack, + ], + [true, 1, true, true, true, `Error: ${carried}`], + ); + }); + }, +); + +suite( + "A Readable whose own `destroy()` throws an ordinary error when binden destroys it", + () => { + test("is not destroyed again: what it throws is logged (`debug`), and its send rejects with what its code has thrown", async () => { + const failure = new Error("Its own destroy() refuses"); + let destroys = 0; + const source = new (class extends Readable { + public override destroy(error?: Error): this { + destroys += 1; + if (typeof error !== "undefined" && !(error instanceof Error)) { + throw failure; + } + return super.destroy(error); + } + })({ + read(): void { + // Never read + }, + }); + const thrown = { message: "A plain object" }; + source.resume = (): never => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- a plain object, as code of a source may throw + throw thrown; + }; + const [status, outcome, captured] = await replied(source); + await captured.waitFor(releaseFailure); + deepStrictEqual( + [status, outcome === thrown, destroys, source.destroyed], + [500, true, 1, false], + ); + deepStrictEqual(releases(captured), [failure.message]); + }); + + for (const mode of ["once", "always"] as const) { + test(`with an error of binden (a body longer than its \`Content-Length\`) is not destroyed again (it throws ${mode}): what it throws is logged`, async () => { + const failure = new Error("Its own destroy() has failed"); + const destroys: unknown[] = []; + const source = new Readable({ + read(): void { + // Pushed below + }, + }); + source.destroy = function destroy( + this: Readable, + error?: Error, + ): Readable { + destroys.push(error); + if (mode === "always" || destroys.length === 1) { + throw failure; + } + return Readable.prototype.destroy.call(this, error); + }; + source.on("error", () => { + // Not destroyed + }); + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + context.response.setHeader("Content-Length", 1); + setImmediate(() => { + source.push("abc"); + }); + await context.send(source).catch((error: unknown) => { + outcome.resolve(error); + }); + }); + const listening = await listen(app); + try { + await send(listening.port); + const error = (await within(outcome.promise)) as { code?: unknown }; + await captured.waitFor(releaseFailure); + deepStrictEqual( + [ + error.code, + destroys.length, + destroys[0] === error, + source.destroyed, + ], + ["ERR_HTTP_CONTENT_LENGTH_MISMATCH", 1, true, false], + ); + deepStrictEqual(releases(captured), [failure.message]); + } finally { + await listening.close(); + } + }); + } + }, +); diff --git a/packages/binden/test/detached-views.test.ts b/packages/binden/test/detached-views.test.ts new file mode 100644 index 00000000..d73cc607 --- /dev/null +++ b/packages/binden/test/detached-views.test.ts @@ -0,0 +1,250 @@ +import { Binden, type Context } from "../index.js"; +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** The message of the refusal of a view of a detached `ArrayBuffer` */ +const refusal = "TypeError: The data is a view of a detached ArrayBuffer"; + +/** + * Runs `fixtures/detached-chunks.ts` in a process of its own, without a + * handler of the process: a crash is its exit code and output + */ +function chunks(mode: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "detached-chunks.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +/** + * Replies one request by `send()` of `data`, then (once it has rejected) by + * `200 replied`: the status and the body that the client gets, and what the + * send has settled with (with whether the headers were sent then) + */ +async function sending( + data: Uint8Array, +): Promise<[number, string, string, unknown]> { + const outcome = Promise.withResolvers(); + let committed: unknown = null; + const app = new Binden().use(async (context: Context) => { + context.response.setHeader("X-Before", "kept"); + try { + await context.send(data); + outcome.resolve("resolved"); + } catch (error: unknown) { + committed = context.response.headersSent; + outcome.resolve( + `${(error as Error).constructor.name}: ${(error as Error).message}`, + ); + await context.response + .status(200) + .send(`replied ${String(context.response.getHeader("X-Before"))}`); + } + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + return [status, body, await within(outcome.promise), committed]; + } finally { + await listening.close(); + } +} + +suite( + "A view of a detached ArrayBuffer is refused before it is written (R24-1)", + () => { + test("given to send(): it rejects with a TypeError of binden before anything is changed, and the work replies", async () => { + const bytes = new Uint8Array(4); + bytes.buffer.transfer(); + deepStrictEqual(await sending(bytes), [ + 200, + "replied kept", + refusal, + false, + ]); + }); + + test("of a SharedArrayBuffer, never detached, given to send(): it is sent", async () => { + const shared = new Uint8Array(new SharedArrayBuffer(2)); + shared.set([0x6f, 0x6b]); + const [status, body, outcome] = await sending(shared); + deepStrictEqual([status, body, outcome], [200, "ok", "resolved"]); + }); + + test("the first chunk of a Readable (a Uint8Array or a DataView): the send rejects with a TypeError of binden before the headers are committed, the work replies, and the pipelined request is answered", () => { + for (const mode of ["first", "view"]) { + deepStrictEqual( + chunks(mode), + { + status: 0, + stdout: `${JSON.stringify({ + send: refusal, + headersSent: false, + destroyed: true, + replies: ["HTTP/1.1 500", "HTTP/1.1 200", "second"], + })}\n`, + stderr: "", + }, + mode, + ); + } + }); + + test("a later chunk of a Readable: the send rejects with a TypeError of binden, and the response is cut, without a crash", () => { + const { status, stdout, stderr } = chunks("later"); + strictEqual(stderr, ""); + strictEqual(status, 0); + const { replies, ...outcome } = JSON.parse(String(stdout)) as Record< + string, + unknown + >; + deepStrictEqual(outcome, { + send: refusal, + headersSent: true, + destroyed: true, + }); + // Never complete, nor followed by the pipelined response + strictEqual(Array.isArray(replies) && replies.includes("second"), false); + }); + }, +); + +suite( + "Bytes that application code detaches inside the write of binden (H26-1)", + () => { + test("by the toString() of a cookie as the head is committed (a view given to send(), a chunk of a Readable with a Content-Length or chunked, or a view whose resizable ArrayBuffer shrinks): the send rejects with the TypeError of binden, formatted, the connection is closed before the head is flushed, and the pipelined response is never read as the body", () => { + for (const mode of ["send", "cl", "chunked", "shrink"]) { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "detaching-writes.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual( + { status, stdout, stderr }, + { + status: 0, + stdout: `${JSON.stringify({ + formatted: true, + send: refusal, + headersSent: true, + destroyed: true, + received: "", + })}\n`, + stderr: "", + }, + mode, + ); + } + }); + + test("by a writer that gives them to binden, then ends the response inside that write, that gives them to binden by end() instead (also of a chunk of a Readable), or that replaces the response with a 406 and drops them without reporting it: the send still rejects with the TypeError of binden, formatted, and the connection is closed, the pipelined response never read as the body (C27-1, H28-1)", () => { + for (const mode of [ + "ending", + "end", + "end-chunks", + "unreported", + "unreported-chunks", + ]) { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "detaching-writes.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual({ status, stderr }, { status: 0, stderr: "" }, mode); + const { received, ...outcome } = JSON.parse(String(stdout)) as Record< + string, + unknown + >; + deepStrictEqual( + outcome, + { + formatted: true, + send: refusal, + headersSent: true, + destroyed: true, + }, + mode, + ); + // The end of the writer may have flushed the head (Node.js uncorks + // the socket), but never a body, nor the pipelined response + ok( + typeof received === "string" && + !received.includes("next") && + (received === "" || received.endsWith("\r\n\r\n")), + `${mode} ${String(received)}`, + ); + } + }); + + test("by the head of a writer that replaces the response inside that write, which drops them, reports it (kDiscardWrite) and calls the write back: no part of the response, the send resolves, and the pipelined request is answered (C27-1)", () => { + for (const mode of ["replaced", "replaced-chunks"]) { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "detaching-writes.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual({ status, stderr }, { status: 0, stderr: "" }, mode); + const { send: settled, received } = JSON.parse( + String(stdout), + ) as Record; + strictEqual(settled, "resolved", mode); + ok( + typeof received === "string" && + /^HTTP\/1\.1 406 [^]*\r\nContent-Length: 0\r\n[^]*\r\n\r\nHTTP\/1\.1 200 [^]*\r\n\r\nnext$/u.test( + received, + ), + `${mode} ${String(received)}`, + ); + } + }); + }, +); + +test("The refusal of send() for a view of a detached ArrayBuffer, kept by the application without its stack read, keeps neither the request nor the response", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "created-error-retention.ts"), + "detached", + ], + { encoding: "utf8", timeout: 30_000 }, + ); + // 60 requests, whose errors are kept + deepStrictEqual( + { status, stdout, stderr }, + { status: 0, stdout: "collected 60 TypeError\n", stderr: "" }, + ); +}); diff --git a/packages/binden/test/early-hints.test.ts b/packages/binden/test/early-hints.test.ts new file mode 100644 index 00000000..ad0a1e6f --- /dev/null +++ b/packages/binden/test/early-hints.test.ts @@ -0,0 +1,993 @@ +import { type AddressInfo, Socket, connect } from "node:net"; +import { Binden, BindenResponse, type Context } from "../index.js"; +import { type IListening, listen, sleep, within } from "./helpers.js"; +import { + IncomingMessage, + OutgoingMessage, + ServerResponse, + createServer, +} from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { kSeal } from "../src/seal.js"; +import { once } from "node:events"; + +const hints = { link: "; rel=preload; as=style" }; +/** `undefined` (no method), which the linter does not let the tests write */ +const absent = ({} as { value?: undefined }).value; +const body = Buffer.from("bytes"); + +/** A promise with its `resolve()` */ +function gate(): { promise: Promise; open(): void } { + const { promise, resolve } = Promise.withResolvers(); + return { promise, open: resolve as () => void }; +} + +/** + * Writes requests on one connection, and reads the responses until the + * server closes it (the last request has `Connection: close`) + */ +async function converse(port: number, requests: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write(requests); + await within(once(socket, "close")); + return data; +} + +/** + * Sends two pipelined requests on one connection, and reads the responses + * until the server closes it (the second request has `Connection: close`) + */ +function pipelined(port: number, second: string): Promise { + return converse( + port, + `GET /first HTTP/1.1\r\nHost: a\r\n\r\nGET ${second} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`, + ); +} + +suite("Informational responses of a pipelined request", () => { + // The first request replies once the second has replied: the response of + // the second (with its `103 Early Hints`) is queued until then + const replied = { current: gate() }; + const replies: Partial unknown>> = { + "/send": (context) => context.send(body), + "/write-head": ({ response }) => { + response.writeHead(200, { "Content-Length": body.length }).end(body); + }, + "/write": ({ response }) => { + response.setHeader("Content-Length", body.length); + response.write(new Uint8Array(body), "utf8", () => { + response.end(); + }); + }, + "/end": ({ response }) => { + response.setHeader("Content-Length", body.length); + response.end(body, () => { + // Ended + }); + }, + "/stream": (context) => + context + .setHeader("Content-Length", body.length) + .send(Readable.from([body])), + "/file": async (context) => { + await context.response.sendFile(import.meta.filename, { + content_type: "text/plain", + }); + }, + }; + + const withApp = async ( + fn: (listening: IListening) => Promise, + ): Promise => { + const app = new Binden({ timeout: 100 }).use(async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/first") { + // Without a deadline: it waits for the second one + context.setTimeout(0); + await replied.current.promise; + await sleep(20); + await context.send("first"); + return; + } else if (pathname === "/delayed") { + context.response.writeEarlyHints(hints); + // Its first chunk never comes: the deadline cancels the send + await context.send( + new Readable({ + read(): void { + // Nothing + }, + }), + ); + return; + } + // Ended by its reply, not by the deadline + context.setTimeout(0); + context.response.writeEarlyHints(hints); + const reply = replies[pathname]?.(context); + replied.current.open(); + await reply; + }); + const listening = await listen(app); + try { + await fn(listening); + } finally { + await listening.close(); + } + }; + + test("are sent before the response, also when its body is bytes", async () => { + await withApp(async ({ port }) => { + for (const path of Object.keys(replies)) { + replied.current = gate(); + const data = await pipelined(port, path); + const [first = "", early = "", second = ""] = + data.split(/(?=HTTP\/1\.1 )/u); + ok(first.startsWith("HTTP/1.1 200 OK\r\n"), `${path}: ${data}`); + ok(first.endsWith("\r\n\r\nfirst"), `${path}: ${first}`); + deepStrictEqual( + early, + `HTTP/1.1 103 Early Hints\r\nLink: ${hints.link}\r\n\r\n`, + path, + ); + ok(second.startsWith("HTTP/1.1 200 OK\r\n"), `${path}: ${second}`); + if (path === "/file") { + ok(second.includes('suite("Informational responses'), path); + } else { + ok( + second.endsWith(`\r\n\r\n${body.toString()}`), + `${path}: ${second}`, + ); + } + } + }); + }); + + test("a send whose first chunk is late is still cancelled by the deadline", async () => { + await withApp(async ({ port }) => { + // The first request replies once the deadline has passed + replied.current = gate(); + setTimeout(() => { + replied.current.open(); + }, 150); + const data = await pipelined(port, "/delayed"); + const [, early = "", second = ""] = data.split(/(?=HTTP\/1\.1 )/u); + deepStrictEqual( + early, + `HTTP/1.1 103 Early Hints\r\nLink: ${hints.link}\r\n\r\n`, + ); + ok(second.startsWith("HTTP/1.1 503 "), second); + }); + }); +}); + +suite("Informational responses to HTTP/1.0", () => { + /** The raw response of one request of `version` (read until the server closes) */ + async function raw(port: number, version: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write( + `GET / HTTP/${version}\r\nHost: a\r\nConnection: close\r\n\r\n`, + ); + await within(once(socket, "close")); + return data; + } + + test("an HTTP/1.0 client never gets an informational response (RFC 9110, 15.2)", async () => { + const callbacks: string[] = []; + const app = new Binden().use(async ({ response }: Context) => { + response.writeContinue(() => callbacks.push("continue")); + response.writeProcessing(() => callbacks.push("processing")); + response.writeEarlyHints(hints, () => callbacks.push("hints")); + await new Promise((resolve) => { + response.end("final", () => { + resolve(null); + }); + }); + }); + const listening = await listen(app); + try { + const old = await raw(listening.port, "1.0"); + ok(old.startsWith("HTTP/1.1 200 OK\r\n"), old); + ok(!/HTTP\/1\.1 1\d\d /u.test(old), old); + ok(old.endsWith("final"), old); + // Ignored, but each callback is called once + deepStrictEqual(callbacks.toSorted(), [ + "continue", + "hints", + "processing", + ]); + + callbacks.length = 0; + const current = await raw(listening.port, "1.1"); + ok(current.startsWith("HTTP/1.1 100 Continue\r\n"), current); + ok(current.includes("HTTP/1.1 102 Processing\r\n"), current); + ok(current.includes("HTTP/1.1 103 Early Hints\r\n"), current); + } finally { + await listening.close(); + } + }); +}); + +suite("Informational responses, then a native write in any encoding", () => { + /** A reply written with the methods of Node.js, which passes `callback` to them */ + type IReply = (response: ServerResponse, callback: () => void) => void; + + interface ICase { + readonly method?: string; + readonly status?: number; + /** How many times the reply passes its callback */ + readonly callbacks: number; + readonly reply: IReply; + } + + const hi = (): Buffer => Buffer.from("hi"); + /** `undefined`, which the linter does not let the tests write */ + const none = ({} as { value?: undefined }).value as unknown as BufferEncoding; + /** Ends the first response of a connection once the second has replied */ + const later = (replied: Promise, response: ServerResponse): void => { + replied.then( + async () => { + await sleep(20); + response.end("first"); + }, + () => { + // Never rejects + }, + ); + }; + // Strings that Node.js frames by an estimate of their length (e.g. an odd + // number of hex digits, which aborts Node.js 24 as it writes them to a + // socket) are not written as Node.js writes them: see `process.test.ts` + const cases: Record = { + "end(string, 'utf-8')": { + callbacks: 0, + reply: (r) => r.end("h\u00e9llo", "utf-8"), + }, + "end(string, 'UTF8')": { + callbacks: 0, + reply: (r) => r.end("hi", "UTF8" as BufferEncoding), + }, + "end(string, 'ascii')": { + callbacks: 0, + reply: (r) => r.end("hi", "ascii"), + }, + "end(string, 'binary')": { + callbacks: 0, + reply: (r) => r.end("h\u00e9", "binary"), + }, + "end(base64, callback)": { + callbacks: 1, + reply: (r, cb) => r.end("aGk=", "base64", cb), + }, + "write(base64) + end()": { + callbacks: 0, + reply: (r) => { + r.write("aGk=", "base64"); + r.end(); + }, + }, + "write(hex, callback) + end(callback)": { + callbacks: 2, + reply: (r, cb) => { + r.write("6869", "hex", cb); + r.end(cb); + }, + }, + "write(HEX) + end()": { + callbacks: 0, + reply: (r) => { + r.write("6869", "HEX" as BufferEncoding); + r.end(); + }, + }, + "end(base64url, callback)": { + callbacks: 1, + reply: (r, cb) => r.end("aGk", "base64url", cb), + }, + // An empty string is no chunk, also in these encodings + "end(string) + end('', 'hex', callback)": { + callbacks: 1, + reply: (r, cb) => { + r.end("hi"); + r.end("", "hex", cb); + }, + }, + "end()": { callbacks: 0, reply: (r) => r.end() }, + "end(callback)": { callbacks: 1, reply: (r, cb) => r.end(cb) }, + "end(bytes)": { callbacks: 0, reply: (r) => r.end(hi()) }, + "end(Uint8Array, 'buffer')": { + callbacks: 0, + reply: (r) => r.end(new Uint8Array(hi()), "buffer" as BufferEncoding), + }, + "write(bytes, undefined, callback) + end()": { + callbacks: 1, + reply: (r, cb) => { + r.write(hi(), none, cb); + r.end(); + }, + }, + "write(bytes, null, callback) + end()": { + callbacks: 1, + reply: (r, cb) => { + r.write(hi(), null as unknown as BufferEncoding, cb); + r.end(); + }, + }, + "end(bytes, undefined, callback)": { + callbacks: 1, + reply: (r, cb) => r.end(hi(), none, cb), + }, + "Content-Length, write(bytes) + end(bytes, callback)": { + callbacks: 1, + reply: (r, cb) => { + r.setHeader("Content-Length", 4); + r.write(hi()); + r.end(hi(), cb); + }, + }, + "writeHead(), a 204 status, end(bytes)": { + callbacks: 0, + reply: (r) => { + r.writeHead(200, { "Content-Length": 2 }).statusCode = 204; + r.end(hi()); + }, + }, + "writeHead() + end(bytes)": { + callbacks: 0, + reply: (r) => r.writeHead(200).end(hi()), + }, + "HEAD: end(bytes)": { + method: "HEAD", + callbacks: 0, + reply: (r) => r.end(hi()), + }, + "HEAD: end(string, 'utf-8', callback)": { + method: "HEAD", + callbacks: 1, + reply: (r, cb) => r.end("hi", "utf-8", cb), + }, + "204: end(bytes)": { status: 204, callbacks: 0, reply: (r) => r.end(hi()) }, + "304: write(hex) + end()": { + status: 304, + callbacks: 0, + reply: (r) => { + r.write("6869", "hex"); + r.end(); + }, + }, + "102: end(bytes)": { status: 102, callbacks: 0, reply: (r) => r.end(hi()) }, + "205: end(bytes)": { status: 205, callbacks: 0, reply: (r) => r.end(hi()) }, + // Ignored by Node.js, whatever their encoding + "HEAD: end(string, 'bogus')": { + method: "HEAD", + callbacks: 0, + reply: (r) => r.end("hi", "bogus" as BufferEncoding), + }, + "204: write(string, 'bogus') + end()": { + status: 204, + callbacks: 0, + reply: (r) => { + r.write("hi", "bogus" as BufferEncoding); + r.end(); + }, + }, + "304: end(bytes, 'bogus')": { + status: 304, + callbacks: 0, + reply: (r) => r.end(hi(), "bogus" as BufferEncoding), + }, + "102: end(string, 'bogus')": { + status: 102, + callbacks: 0, + reply: (r) => r.end("hi", "bogus" as BufferEncoding), + }, + "end('', 'bogus')": { + callbacks: 0, + reply: (r) => r.end("", "bogus" as BufferEncoding), + }, + "end(string) + write(string, 'bogus', callback)": { + callbacks: 1, + reply: (r, cb) => { + // Node.js emits `ERR_STREAM_WRITE_AFTER_END` + r.on("error", () => { + // Expected + }); + r.end("hi"); + r.write("hi", "bogus" as BufferEncoding, cb); + }, + }, + }; + + /** The second response of a connection, without its `Date` */ + const undated = (data: string): string => + data.replaceAll(/\r\nDate: [^\r]*/gu, ""); + + /** + * Serves the case of a path with `Response` (and the first request of a + * pipelined pair, which replies once the second has replied), and reads + * the requests from one connection until the server closes it + */ + async function exchange( + Response: typeof ServerResponse, + requests: string, + informational: boolean, + ): Promise<{ data: string; calls: Map }> { + const calls = new Map(); + const replied = gate(); + const server = createServer( + { ServerResponse: Response as typeof BindenResponse }, + (request: IncomingMessage, response: ServerResponse) => { + const path = decodeURIComponent(request.url ?? "/").slice(1); + if (path === "first") { + later(replied.promise, response); + return; + } + const { status = 200, reply } = cases[path]; + if (informational) { + response.writeEarlyHints({ link: "; rel=preload" }); + } + response.statusCode = status; + reply(response, () => { + calls.set(path, (calls.get(path) ?? 0) + 1); + }); + replied.open(); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write(requests); + await within(once(socket, "close")); + // The callbacks of the last writes run once they are flushed + await sleep(20); + return { data, calls }; + } finally { + server.closeAllConnections(); + server.close(); + } + } + + test("are sent first, and the response is framed as Node.js frames it", async () => { + for (const [name, { method = "GET", callbacks }] of Object.entries(cases)) { + const target = `/${encodeURIComponent(name)}`; + const last = `${method} ${target} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`; + // Node.js itself, with nothing queued before the response + const native = await exchange(ServerResponse, last, false); + const queued = await exchange( + BindenResponse, + `GET /first HTTP/1.1\r\nHost: a\r\n\r\n${last}`, + true, + ); + const early = + "HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n"; + const index = queued.data.indexOf(early); + ok(index > 0, `${name}: ${queued.data}`); + ok(queued.data.slice(0, index).endsWith("\r\n\r\nfirst"), name); + deepStrictEqual( + undated(queued.data.slice(index + early.length)), + undated(native.data), + name, + ); + deepStrictEqual(native.calls.get(name) ?? 0, callbacks, name); + deepStrictEqual(queued.calls.get(name) ?? 0, callbacks, name); + } + }); + + test("a chunk that Node.js refuses sends nothing, and the response can still reply", async () => { + const invalid: Record unknown]> = { + "unknown encoding": [ + "ERR_UNKNOWN_ENCODING", + (r): unknown => r.end("hi", "bogus" as BufferEncoding), + ], + "unknown encoding of a length": [ + "ERR_UNKNOWN_ENCODING", + (r): unknown => + r.setHeader("Content-Length", 2).end("hi", "bogus" as BufferEncoding), + ], + "unknown encoding of bytes": [ + "ERR_UNKNOWN_ENCODING", + (r): unknown => r.write(hi(), "bogus" as BufferEncoding), + ], + "bytes encoding of a string": [ + "ERR_UNKNOWN_ENCODING", + (r): unknown => r.end("hi", "buffer" as BufferEncoding), + ], + "encoding that is not a string": [ + "ERR_UNKNOWN_ENCODING", + (r): unknown => r.end(hi(), 1 as unknown as BufferEncoding), + ], + "not bytes": ["ERR_INVALID_ARG_TYPE", (r): unknown => r.end(1)], + null: ["ERR_STREAM_NULL_VALUES", (r): unknown => r.write(null)], + }; + // After early hints, behind another response (where Node.js would crash + // the process as it flushes the head), and alone + const modes = { hints: true, pipelined: true, alone: false }; + for (const [mode, behind] of Object.entries(modes)) { + for (const [name, [code, reply]] of Object.entries(invalid)) { + const replied = gate(); + const outcomes: unknown[] = []; + const server = createServer( + { ServerResponse: BindenResponse }, + (request: IncomingMessage, response: ServerResponse) => { + if (request.url === "/first") { + later(replied.promise, response); + return; + } else if (mode === "hints") { + response.writeEarlyHints({ link: "; rel=preload" }); + } + try { + reply(response); + } catch (error: unknown) { + outcomes.push( + (error as { code?: unknown }).code, + response.headersSent, + ); + response.setHeader("Content-Length", 8).end("fallback"); + } + replied.open(); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const { port } = server.address() as AddressInfo; + const data = behind + ? await pipelined(port, "/second") + : await converse( + port, + "GET /second HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const key = `${mode}: ${name}`; + deepStrictEqual(outcomes, [code, false], key); + const responses = undated(data).split(/(?=HTTP\/1\.1 )/u); + deepStrictEqual( + responses.slice(behind ? 1 : 0), + [ + ...(mode === "hints" + ? [ + "HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n", + ] + : []), + "HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nfallback", + ], + key, + ); + } finally { + server.closeAllConnections(); + server.close(); + } + } + } + }); + + test("a destroyed response refuses nothing, as with Node.js", async () => { + const results: unknown[] = []; + for (const Response of [ServerResponse, BindenResponse]) { + const replied = gate(); + const outcomes: unknown[] = []; + const server = createServer( + { ServerResponse: Response as typeof BindenResponse }, + (request: IncomingMessage, response: ServerResponse) => { + if (request.url === "/first") { + later(replied.promise, response); + return; + } + response.writeEarlyHints({ link: "; rel=preload" }); + response.destroy(); + response.end(hi(), "bogus" as BufferEncoding, () => { + outcomes.push("called"); + }); + outcomes.push("returned"); + replied.open(); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const socket = connect( + (server.address() as AddressInfo).port, + "127.0.0.1", + ); + try { + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write( + "GET /first HTTP/1.1\r\nHost: a\r\n\r\nGET /second HTTP/1.1\r\nHost: a\r\n\r\n", + ); + // Node.js keeps the connection open: what it has sent by then + await sleep(200); + results.push([undated(data), outcomes]); + } finally { + socket.destroy(); + server.closeAllConnections(); + server.close(); + } + } + const [native, queued] = results; + deepStrictEqual(queued, native); + ok(JSON.stringify(native).includes("returned")); + }); + + test("the chunk is written as it is: only the empty commit is a string", async () => { + const write = Object.getOwnPropertyDescriptor( + ServerResponse.prototype, + "write", + ); + const end = Object.getOwnPropertyDescriptor( + ServerResponse.prototype, + "end", + ); + ok(typeof write === "undefined" && typeof end === "undefined"); + const native = OutgoingMessage.prototype as unknown as Record< + "end" | "write", + (...args: unknown[]) => unknown + >; + const large = Buffer.alloc(1 << 20, 97); + const parts = [large.subarray(0, 10), large.subarray(10, 20)]; + /** The calls of Node.js (by a response after early hints, or alone) */ + const calls = async (informed: boolean): Promise<[string, unknown[]][]> => { + const replied = gate(); + const seen: [string, unknown[]][] = []; + const server = createServer( + { ServerResponse: BindenResponse }, + (request: IncomingMessage, response: ServerResponse) => { + if (request.url === "/first") { + later(replied.promise, response); + return; + } else if (informed) { + response.writeEarlyHints({ link: "; rel=preload" }); + } + // What reaches Node.js + Object.defineProperties(ServerResponse.prototype, { + write: { + configurable: true, + value(this: ServerResponse, ...args: unknown[]): unknown { + seen.push(["write", args]); + return Reflect.apply(native.write, this, args); + }, + }, + end: { + configurable: true, + value(this: ServerResponse, ...args: unknown[]): unknown { + seen.push(["end", args]); + return Reflect.apply(native.end, this, args); + }, + }, + }); + try { + response.setHeader("Content-Length", large.length); + response.write(parts[0]); + response.write(parts[1], "utf8"); + response.end(large.subarray(20), "base64"); + } finally { + Reflect.deleteProperty(ServerResponse.prototype, "write"); + Reflect.deleteProperty(ServerResponse.prototype, "end"); + } + replied.open(); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const data = await pipelined( + (server.address() as AddressInfo).port, + "/second", + ); + ok(data.endsWith(`\r\n\r\n${large.toString()}`)); + return seen; + } finally { + server.closeAllConnections(); + server.close(); + } + }; + for (const informed of [true, false]) { + const seen = await calls(informed); + // The same chunks (not copies), each once + deepStrictEqual( + seen.map(([name, args]) => [name, args.length]), + [ + ...(informed ? [["write", 2]] : []), + ["write", 1], + ["write", 2], + ["end", 2], + ], + ); + if (informed) { + deepStrictEqual(seen[0]?.[1], ["", "latin1"]); + } + const chunks = seen.slice(informed ? 1 : 0).map(([, args]) => args[0]); + ok(chunks[0] === parts[0] && chunks[1] === parts[1]); + ok( + chunks[2] instanceof Buffer && + chunks[2].buffer === large.buffer && + chunks[2].byteOffset === large.byteOffset + 20, + ); + } + }); +}); + +suite("writeInformation() on every version of Node.js", () => { + /** A response to an HTTP/1.1 request, without a connection */ + function response(): BindenResponse { + const request = new IncomingMessage(new Socket()); + request.httpVersionMajor = 1; + request.httpVersionMinor = 1; + return new BindenResponse(request); + } + + /** Runs `run` with `writeInformation()` of Node.js replaced by `method` */ + function replacing(method: unknown, run: () => void): void { + const prototype = ServerResponse.prototype as object; + const descriptor = Reflect.getOwnPropertyDescriptor( + prototype, + "writeInformation", + ); + Reflect.defineProperty(prototype, "writeInformation", { + configurable: true, + writable: true, + value: method, + }); + try { + run(); + } finally { + if (typeof descriptor === "undefined") { + Reflect.deleteProperty(prototype, "writeInformation"); + } else { + Reflect.defineProperty(prototype, "writeInformation", descriptor); + } + } + } + + test("each declared signature reaches the method of Node.js: a callback in place of the headers is given no headers", () => { + const calls: unknown[][] = []; + const callback = (): void => { + // Nothing + }; + replacing( + (...args: unknown[]): void => { + calls.push(args.map((arg) => typeof arg)); + }, + () => { + const informing = response(); + informing.writeInformation(102, callback); + informing.writeInformation(103, hints, callback); + informing.writeInformation(103, hints); + informing.writeInformation(102); + }, + ); + deepStrictEqual(calls, [ + ["number", "object", "function"], + ["number", "object", "function"], + ["number", "object"], + ["number"], + ]); + }); + + test("without the method of Node.js (before 24.18.0): a TypeError of binden, formatted, unless the call is ignored (HTTP/1.0)", () => { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + replacing(absent, () => { + let thrown: unknown = null; + try { + response().writeInformation(102, {}); + } catch (error: unknown) { + thrown = error; + } + ok(thrown instanceof TypeError); + ok(formatted.has(thrown)); + deepStrictEqual( + thrown.message, + "writeInformation() is not supported by this version of Node.js (24.18.0 or later)", + ); + // An HTTP/1.0 request: ignored, as it would be with the method + new BindenResponse(new IncomingMessage(new Socket())).writeInformation( + 102, + {}, + ); + }); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + }); +}); + +suite("Informational responses once the head is committed (H27-2)", () => { + /** A response to a request of an HTTP version, without a connection */ + function response(major = 1, minor = 1): BindenResponse { + const request = new IncomingMessage(new Socket()); + request.httpVersionMajor = major; + request.httpVersionMinor = minor; + return new BindenResponse(request); + } + + /** The informational calls, by name (`writeEarlyHints()` with hints that Node.js 26 ignores) */ + const calls: Record< + string, + (response: BindenResponse, done: () => void) => void + > = { + writeContinue: (informing, done): void => { + informing.writeContinue(done); + }, + writeProcessing: (informing, done): void => { + informing.writeProcessing(done); + }, + writeEarlyHints: (informing, done): void => { + informing.writeEarlyHints({}, done); + }, + "writeEarlyHints() of a link": (informing, done): void => { + informing.writeEarlyHints(hints, done); + }, + writeInformation: (informing, done): void => { + informing.writeInformation(102, done); + }, + }; + + /** + * Runs `run` with `writeInformation()` of Node.js (absent before 24.18.0) + * replaced by one that records its calls + */ + function recording(run: () => void): number { + const prototype = ServerResponse.prototype as object; + const descriptor = Reflect.getOwnPropertyDescriptor( + prototype, + "writeInformation", + ); + let called = 0; + Reflect.defineProperty(prototype, "writeInformation", { + configurable: true, + writable: true, + value: (): void => { + called += 1; + }, + }); + try { + run(); + } finally { + if (typeof descriptor === "undefined") { + Reflect.deleteProperty(prototype, "writeInformation"); + } else { + Reflect.defineProperty(prototype, "writeInformation", descriptor); + } + } + return called; + } + + test("throw ERR_HTTP_HEADERS_SENT, formatted, on every version of Node.js, and queue nothing; ignored calls (HTTP/1.0, a sealed response) stay ignored", async () => { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + const outcomes: unknown[] = []; + const ignored: Promise[] = []; + try { + for (const [name, call] of Object.entries(calls)) { + const committed = response(); + committed.writeHead(200); + const queued = Reflect.get(committed, "outputData") as unknown[]; + const before = queued.length; + let thrown: unknown = null; + const called = recording(() => { + try { + call(committed, () => { + // Never + }); + } catch (error: unknown) { + thrown = error; + } + }); + const { code, message } = thrown as { + code?: unknown; + message?: unknown; + }; + outcomes.push([ + name, + thrown instanceof Error, + code, + message, + formatted.has(thrown as object), + queued.length - before, + called, + ]); + // Ignored, the head committed as well: the callback is called + for (const informing of [response(1, 0), response()]) { + informing.writeHead(200); + if (informing.req.httpVersionMinor === 1) { + informing[kSeal](); + } + const { promise, resolve } = Promise.withResolvers(); + call(informing, () => { + resolve(name); + }); + ignored.push(within(promise)); + } + } + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + deepStrictEqual( + outcomes, + Object.keys(calls).map((name) => [ + name, + true, + "ERR_HTTP_HEADERS_SENT", + "Cannot write headers after they are sent to the client", + true, + 0, + 0, + ]), + ); + deepStrictEqual( + (await Promise.all(ignored)).length, + Object.keys(calls).length * 2, + ); + }); + + test("a flushed head whose Content-Length frames the body: the call throws, and the body and the pipelined response are read as sent", async () => { + const outcomes: string[] = []; + const app = new Binden().use((context: Context) => { + const name = decodeURIComponent(context.url.pathname.slice(1)); + if (name === "next") { + context.response.end("next"); + return; + } + context.response.setHeader("Content-Length", 5); + context.response.flushHeaders(); + try { + calls[name](context.response, () => { + // Never + }); + outcomes.push(`${name}: sent`); + } catch (error: unknown) { + outcomes.push(`${name}: ${String((error as { code?: unknown }).code)}`); + } + context.response.end("hello"); + }); + const listening = await listen(app); + try { + for (const name of Object.keys(calls)) { + const data = await converse( + listening.port, + `GET /${encodeURIComponent(name)} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + deepStrictEqual( + data.replaceAll(/\r\nDate: [^\r]*/gu, ""), + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\nhello" + + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\nnext", + name, + ); + } + } finally { + await listening.close(); + } + deepStrictEqual( + outcomes, + Object.keys(calls).map((name) => `${name}: ERR_HTTP_HEADERS_SENT`), + ); + }); +}); diff --git a/packages/binden/test/empty-sends.test.ts b/packages/binden/test/empty-sends.test.ts new file mode 100644 index 00000000..b38fa97f --- /dev/null +++ b/packages/binden/test/empty-sends.test.ts @@ -0,0 +1,76 @@ +import { Binden, type Context } from "../index.js"; +import { HOST, listen, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { PassThrough } from "node:stream"; +import { connect } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; +import { once } from "node:events"; + +/** How a send has settled: resolved, or the code (else the message) of its error */ +function outcomeOf(sent: Promise): Promise { + return sent.then( + () => "resolved", + (error: unknown) => { + const { code, message } = error as { code?: unknown; message?: unknown }; + return String(code ?? message); + }, + ); +} + +suite("A send of a stream with a declared `Content-Length: 0`", () => { + test("is complete only once the response has finished: a client that goes away before its head rejects it", async () => { + const { promise: started, resolve: starts } = Promise.withResolvers(); + const { promise: settled, resolve: settles } = + Promise.withResolvers<[string, boolean]>(); + const listening = await listen( + new Binden().use(async (context: Context) => { + context.response.setHeader("Content-Length", 0); + // Never ends: nothing is written, not even the head + const sent = context.send(new PassThrough()); + starts(null); + settles([await outcomeOf(sent), context.response.headersSent]); + }), + ); + try { + const socket = connect(listening.port, HOST); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await within(started); + socket.destroy(); + deepStrictEqual( + [await within(settled), received], + [["ERR_STREAM_PREMATURE_CLOSE", false], ""], + ); + } finally { + await listening.close(); + } + }); + + test("of a stream that ends resolves once its empty reply has finished", async () => { + const outcomes: string[] = []; + const listening = await listen( + new Binden().use(async (context: Context) => { + context.response.setHeader("Content-Length", 0); + const source = new PassThrough(); + source.end(); + outcomes.push(await outcomeOf(context.send(source))); + }), + ); + try { + const response = await within( + fetch(`${listening.url}/`).then(async (reply) => [ + reply.status, + reply.headers.get("Content-Length"), + await reply.text(), + ]), + ); + deepStrictEqual([response, outcomes], [[200, "0", ""], ["resolved"]]); + } finally { + await listening.close(); + } + }); +}); diff --git a/packages/binden/test/ended-sends.test.ts b/packages/binden/test/ended-sends.test.ts new file mode 100644 index 00000000..652ed163 --- /dev/null +++ b/packages/binden/test/ended-sends.test.ts @@ -0,0 +1,570 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { type ICaptured, capture, ioTurn, listen, within } from "./helpers.js"; +import { PassThrough, Readable } from "node:stream"; +import { converse, get } from "./connection.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; + +/** A body larger than the buffers of a connection on any platform */ +const large = 16 << 20; + +/** How a send has settled: `resolved`, or what it has rejected with */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => error, + ); +} + +/** The code (or else the message) of each late error of a source logged so far */ +function reported(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => { + const { code, message } = failure as { + code?: unknown; + message?: unknown; + }; + return code ?? message; + }); +} + +/** Whether the reply of `/next` has been received in full (the last one) */ +function nextReceived(received: string): boolean { + return received.endsWith("\r\n\r\nnext"); +} + +/** + * The first response of what has been received: the length of its body (of + * its chunks, or of its `Content-Length`), whether that body is `body`, and + * whether the reply of `/next` follows it (and nothing else) + */ +function first(received: string, body: string): [number, boolean, boolean] { + const end = received.indexOf("\r\n\r\n"); + const head = received.slice(0, end).toLowerCase(); + let at = end + 4; + let content = ""; + if (head.includes("\r\ntransfer-encoding: chunked")) { + let size = Number.NaN; + while (size !== 0) { + const line = received.indexOf("\r\n", at); + size = Number.parseInt(received.slice(at, line), 16); + if (Number.isNaN(size) || line === -1) { + return [content.length, false, false]; + } + content += received.slice(line + 2, line + 2 + size); + // The chunk, then its line end (the last chunk: the end of the trailers) + at = line + 2 + size + 2; + } + } else { + const length = Number( + /\r\ncontent-length: (?\d+)/u.exec(head)?.groups?.length, + ); + content = received.slice(at, at + length); + at += length; + } + const rest = received.slice(at); + return [ + content.length, + content === body, + rest.startsWith("HTTP/1.1 200 OK\r\n") && nextReceived(rest), + ]; +} + +/** + * Runs `scenario` as the middleware of `/`, and `/next` replies `next`: the + * client writes `text` (once `hold` resolves, it reads), and gets what it + * receives, how the send of the scenario has settled, and the logger + */ +async function run( + scenario: (context: Context) => Promise, + text: string, + { + hold, + complete = nextReceived, + slow, + }: { + readonly hold?: Promise | undefined; + readonly complete?: ((received: string) => boolean) | undefined; + /** What `/slow` waits for before it replies `slow` */ + readonly slow?: Promise | undefined; + } = {}, +): Promise<[string, boolean, unknown, ICaptured]> { + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + } else if (context.url.pathname === "/slow") { + await slow; + await context.text("slow"); + } else { + outcome.resolve(await scenario(context)); + } + }); + const listening = await listen(app); + try { + const { received, closed } = await converse(listening.port, text, { + complete, + hold, + }); + const settled = await within(outcome.promise); + // A late error is reported on a later turn (see `reportLate()`) + await ioTurn(); + return [received, closed, settled, captured]; + } finally { + await listening.close(); + } +} + +suite("A send whose response another writer has ended", () => { + for (const pipelined of [false, true]) { + test(`while its paused source waits for the drain, then the source fails: the reply is complete${pipelined ? ", and the next response is sent" : ""}`, async () => { + const failed = Promise.withResolvers(); + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + response.setHeader("Content-Type", "text/plain"); + // Needs a drain: the source waits for it + response.write(Buffer.alloc(large, 0x61)); + const source = new Readable({ + read(): void { + // Never read + }, + }); + source.pause(); + const sent = outcomeOf(context.send(source)); + response.end("END"); + source.destroy(new Error("The source has failed")); + // Once its error is emitted + await new Promise(setImmediate); + failed.resolve(null); + return sent; + }, + pipelined ? `${get("/")}${get("/next")}` : get("/"), + { + hold: failed.promise, + complete: pipelined + ? nextReceived + : (text): boolean => text.endsWith("\r\n0\r\n\r\n"), + }, + ); + deepStrictEqual( + [ + first(received, `${"a".repeat(large)}END`), + closed, + outcome, + reported(captured), + ], + [ + [large + 3, true, pipelined], + false, + "resolved", + ["The source has failed"], + ], + ); + }); + } + + for (const [how, failing, logged] of [ + [ + "fails", + (source: Readable): void => { + source.destroy(new Error("The source has failed")); + }, + ["The source has failed"], + ], + [ + "closes", + (source: Readable): void => { + source.destroy(); + }, + [], + ], + [ + "is destroyed with an error that its `_destroy()` swallows (no `error`)", + (source: Readable): void => { + source.destroy(new Error("Swallowed")); + }, + ["Swallowed"], + ], + ] as const) { + for (const queued of [false, true]) { + test(`before its first chunk, with a complete reply, then its source ${how}${queued ? ", while the response waits for the one before it" : ""}: the reply and the next response are sent`, async () => { + const failed = Promise.withResolvers(); + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + const source = new PassThrough({ + destroy(error, callback): void { + callback(how.startsWith("is destroyed") ? null : error); + }, + }); + const sent = outcomeOf(context.send(source)); + response.end("BBBB"); + failing(source); + // Once its failure is emitted, the response before it replies + await new Promise(setImmediate); + failed.resolve(null); + return sent; + }, + queued + ? `${get("/slow")}${get("/")}${get("/next")}` + : `${get("/")}${get("/next")}`, + { slow: failed.promise }, + ); + const bodies = received.match(/\r\n\r\n(?:slow|BBBB|next)/gu); + deepStrictEqual( + [bodies, closed, outcome, reported(captured)], + [ + [ + ...(queued ? ["\r\n\r\nslow"] : []), + "\r\n\r\nBBBB", + "\r\n\r\nnext", + ], + false, + "resolved", + logged, + ], + ); + }); + } + } + + for (const failure of [new Error("The source has failed"), null]) { + test(`with a complete reply too large to be flushed at once, then its source ${failure === null ? "closes" : "fails"}: the reply and the next response are sent`, async () => { + const failed = Promise.withResolvers(); + const [received, closed, outcome] = await run( + async (context) => { + const { response } = context; + const source = new PassThrough(); + const sent = outcomeOf(context.send(source)); + response.setHeader("Content-Length", large); + response.end(Buffer.alloc(large, 0x61)); + if (failure === null) { + source.destroy(); + } else { + source.destroy(failure); + } + await new Promise(setImmediate); + failed.resolve(null); + return sent; + }, + `${get("/")}${get("/next")}`, + { hold: failed.promise }, + ); + deepStrictEqual( + [first(received, "a".repeat(large)), closed, outcome], + [[large, true, true], false, "resolved"], + ); + }); + } + + test("once its source has been paused by its backpressure, then the source fails: the reply and the next response are sent", async () => { + const failed = Promise.withResolvers(); + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + response.setHeader("Content-Type", "text/plain"); + let pushed = false; + let paused = false; + const source = new (class extends Readable { + public override pause(): this { + super.pause(); + if (!paused) { + paused = true; + // As the work, once the send has paused it + queueMicrotask(() => { + response.end(); + this.destroy(new Error("The source has failed")); + setImmediate(() => { + failed.resolve(null); + }); + }); + } + return this; + } + })({ + read(): void { + if (!pushed) { + pushed = true; + this.push(Buffer.alloc(large, 0x61)); + } + }, + }); + return outcomeOf(context.send(source)); + }, + `${get("/")}${get("/next")}`, + { hold: failed.promise }, + ); + deepStrictEqual( + [first(received, "a".repeat(large)), closed, outcome, reported(captured)], + [[large, true, true], false, "resolved", ["The source has failed"]], + ); + }); + + test("before its source ends, short of the `Content-Length` that the send has read: the reply and the next response are sent, and nothing is logged", async () => { + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + response.setHeader("Content-Length", 3); + const source = new PassThrough(); + const sent = outcomeOf(context.send(source)); + response.end("abc"); + source.end(); + return sent; + }, + `${get("/")}${get("/next")}`, + ); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:abc|next)/gu), + closed, + outcome, + reported(captured), + ], + [["\r\n\r\nabc", "\r\n\r\nnext"], false, "resolved", []], + ); + }); + + test("whose source then errors without being destroyed (`autoDestroy: false`): the source is released at once, and its error logged once", async () => { + const source = new Readable({ + autoDestroy: false, + read(): void { + // Pushed below + }, + }); + const failed = Promise.withResolvers(); + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + const sent = outcomeOf(context.send(source)); + response.setHeader("Content-Length", large); + response.end(Buffer.alloc(large, 0x61)); + // Not bytes: it errors, but is not destroyed + source.push({}); + await new Promise(setImmediate); + // Released at once, before the response has finished where its client + // does not read yet (Windows takes the whole write while its send + // buffer has room: its response may have finished already) + const released = source.destroyed; + failed.resolve(null); + return [await sent, released]; + }, + `${get("/")}${get("/next")}`, + { hold: failed.promise }, + ); + deepStrictEqual( + [first(received, "a".repeat(large)), closed, outcome, reported(captured)], + [ + [large, true, true], + false, + ["resolved", true], + ["ERR_INVALID_ARG_TYPE"], + ], + ); + }); + + test("whose source then fails once released (its chunk comes after the end): its error is logged once", async () => { + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + const source = new Readable({ + read(): void { + // Pushed below + }, + destroy(_error, callback): void { + callback(new Error("Its release has failed")); + }, + }); + const sent = outcomeOf(context.send(source)); + response.end("ended"); + // Read once the response has ended: released + source.push("late"); + return sent; + }, + `${get("/")}${get("/next")}`, + ); + // Logged by its guard only (the send hears it too) + await new Promise(setImmediate); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:ended|late|next)/gu), + closed, + outcome, + reported(captured), + ], + [ + ["\r\n\r\nended", "\r\n\r\nnext"], + false, + "resolved", + ["Its release has failed"], + ], + ); + }); + + for (const errored of [false, true]) { + test(`before a source that code of the source finds destroyed${errored ? " and errored" : ""}: the send settles as the response${errored ? ", and the error of the source is logged once" : ""}`, async () => { + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + const source = new Readable({ + autoDestroy: false, + read(): void { + // Never read + }, + }); + if (errored) { + source.on("error", () => { + // Its error is `errored` + }); + // Not bytes: it errors, but is not destroyed + source.push({}); + } + // A getter of its own that ends the response once `send()` has + // checked it + Object.defineProperty(source, "destroyed", { + get(): boolean { + response.end("ended"); + return true; + }, + }); + return outcomeOf(context.send(source)); + }, + `${get("/")}${get("/next")}`, + ); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:ended|next)/gu), + closed, + outcome, + reported(captured), + ], + [ + ["\r\n\r\nended", "\r\n\r\nnext"], + false, + "resolved", + errored ? ["ERR_INVALID_ARG_TYPE"] : [], + ], + ); + }); + } +}); + +suite("A send that has ended its response", () => { + test("with its whole body, whose source then fails to be destroyed (`autoDestroy`): the reply and the next response are sent", async () => { + const failed = Promise.withResolvers(); + const [received, closed, outcome, captured] = await run( + async (context) => { + const { response } = context; + let pushed = false; + const source = new Readable({ + read(): void { + if (!pushed) { + pushed = true; + this.push(Buffer.alloc(large, 0x61)); + } + this.push(null); + }, + destroy(_error, callback): void { + callback(new Error("Its destroy has failed")); + }, + }); + source.on("error", () => { + // Once the send has heard it + setImmediate(() => { + failed.resolve(null); + }); + }); + response.setHeader("Content-Length", large); + return outcomeOf(context.send(source)); + }, + `${get("/")}${get("/next")}`, + { hold: failed.promise }, + ); + deepStrictEqual( + [first(received, "a".repeat(large)), closed, outcome, reported(captured)], + [[large, true, true], false, "resolved", ["Its destroy has failed"]], + ); + }); +}); + +suite( + "A Readable that has errored before the send, without being destroyed", + () => { + /** A source that has errored (a chunk that is not bytes), which `autoDestroy: false` keeps */ + function errored(): Readable { + const source = new Readable({ + autoDestroy: false, + read(): void { + // Never read + }, + }); + source.on("error", () => { + // Its error is `errored` + }); + source.push({}); + return source; + } + + test("rejects at once with its error, which is replied, and is released", async () => { + const source = errored(); + deepStrictEqual(source.destroyed, false); + const [received, closed, outcome] = await run( + async (context) => { + const error = await outcomeOf(context.send(source)); + return [ + (error as { code?: unknown }).code, + error === source.errored, + context.response.started, + ]; + }, + `${get("/")}${get("/next")}`, + ); + // The middleware has not replied: 404 + ok(received.startsWith("HTTP/1.1 404 Not Found\r\n"), received); + deepStrictEqual( + [closed, outcome, source.destroyed], + [false, ["ERR_INVALID_ARG_TYPE", true, false], true], + ); + }); + + test("closes a response whose headers are committed", async () => { + const [received, closed, outcome] = await run( + async (context) => { + const { response } = context; + response.setHeader("Content-Length", 3); + response.flushHeaders(); + const error = await outcomeOf(context.send(errored())); + return [(error as { code?: unknown }).code, response.destroyed]; + }, + `${get("/")}${get("/next")}`, + ); + deepStrictEqual( + [received.includes("next"), closed, outcome], + [false, true, ["ERR_INVALID_ARG_TYPE", true]], + ); + }); + + test("destroyed with an error before the send: rejects at once with that error", async () => { + const failure = new Error("Destroyed before"); + const source = new Readable({ + read(): void { + // Never read + }, + }); + source.on("error", () => { + // Its error is `errored` + }); + source.destroy(failure); + const [, , outcome] = await run( + (context) => outcomeOf(context.send(source)), + `${get("/")}${get("/next")}`, + ); + deepStrictEqual(outcome, failure); + }); + }, +); diff --git a/packages/binden/test/entry-boundaries.test.ts b/packages/binden/test/entry-boundaries.test.ts new file mode 100644 index 00000000..68246a05 --- /dev/null +++ b/packages/binden/test/entry-boundaries.test.ts @@ -0,0 +1,968 @@ +import { + Binden, + BindenAbortError, + BindenError, + BindenRequest, + BindenResponse, + Context, + type IBindenOptions, + type IServer, + Middleware, + Router, + createSerializers, + described, + formatting, + framingLength, + isClosing, + serializers, + shutdown, + validateRequestOptions, +} from "../index.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { Socket } from "node:net"; +import { createServer } from "node:http"; +import { within } from "./helpers.js"; + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** A value of any type, given in place of the one that a method declares */ +function given(value: unknown): T { + return value as T; +} + +/** A proxy that has been revoked: any operation on it throws */ +function revoked(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** The error of the test, which a trap of a proxy throws (see `throwing()`) */ +let own = new RangeError("A trap of the test"); + +/** + * A proxy whose every trap throws the error of the test, a new one (whose + * stack is not formatted yet) + */ +function throwing(): object { + const error = new RangeError("A trap of the test"); + own = error; + const trap = (): never => { + throw error; + }; + return new Proxy( + {}, + { get: trap, getPrototypeOf: trap, ownKeys: trap, has: trap }, + ); +} + +/** The conversions of the values of `counting()` */ +let conversions = 0; + +/** + * An object that counts its conversions (`toString()`, `valueOf()` and + * `Symbol.toPrimitive`): a refusal must not convert it + */ +function counting(text = "a"): object { + const convert = (): string => { + conversions += 1; + return text; + }; + return { toString: convert, valueOf: convert, [Symbol.toPrimitive]: convert }; +} + +/** Records the errors whose stacks V8 formats while `run` runs */ +function recorded(run: (formatted: WeakSet) => T): T { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + return run(formatted); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** What a thrown value tells: its class, its message, whether its stack was formatted and whether it is the error of the test */ +function told(error: unknown, formatted: WeakSet): unknown { + const { constructor, message } = error as Error; + return [ + constructor.name, + message, + formatted.has(error as Error), + error === own, + ]; +} + +/** + * What a call has done: `returned`, or what its error tells (see `told()`) + * once the caller gets it + */ +function outcome(run: () => unknown): unknown { + return recorded((formatted) => { + try { + run(); + return "returned"; + } catch (error: unknown) { + return told(error, formatted); + } + }); +} + +/** What a call that returns a promise has rejected with (see `outcome()`) */ +async function rejection(run: () => Promise): Promise { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + await within(run()); + return "resolved"; + } catch (error: unknown) { + return told(error, formatted); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A refusal of binden: a formatted `TypeError` of the message */ +function refused(message: string): unknown { + return ["TypeError", message, true, false]; +} + +/** The formatted error of V8 for a revoked proxy, of an operation */ +function revokedError(operation: string): unknown { + return [ + "TypeError", + `Cannot perform '${operation}' on a proxy that has been revoked`, + true, + false, + ]; +} + +/** The error of the test, formatted */ +const ownError = ["RangeError", "A trap of the test", true, true]; + +/** A middleware class of the test */ +class Noop extends Middleware { + // eslint-disable-next-line class-methods-use-this -- nothing to do + public run(): void { + // Nothing + } +} + +/** + * The constructors and functions whose options (an object) are optional, and + * how V8 refuses to read a revoked proxy of them (a destructuring gets its + * properties, a spread its keys) + */ +const optional: readonly (readonly [ + string, + (options: unknown) => unknown, + string, +])[] = [ + [ + "new BindenError()", + (options): unknown => new BindenError(404, given(options)), + "get", + ], + ["new Middleware()", (options): unknown => new Noop(given(options)), "get"], + ["new Router()", (options): unknown => new Router(given(options)), "get"], + [ + "new Binden()", + (options): unknown => new Binden(given(options)), + "get", + ], + [ + "createServer()", + (options): unknown => new Binden().createServer(given(options)), + "ownKeys", + ], + [ + "createSecureServer()", + (options): unknown => new Binden().createSecureServer(given(options)), + "ownKeys", + ], + [ + "createSerializers()", + (options): unknown => createSerializers(given(options)), + "get", + ], + [ + "validateRequestOptions()", + (options): unknown => validateRequestOptions(given(options)), + "get", + ], + [ + "shutdown()", + (options): unknown => shutdown(createServer(), given(options)), + "get", + ], +]; + +suite( + "The options of a constructor or a function of binden are an object, read inside the boundary that formats what they throw (R27-1)", + () => { + test("anything but an object (or undefined) is refused, described (a string was read as an object without the options)", () => { + for (const [name, create] of optional) { + const options = + name === "validateRequestOptions()" + ? "`options` is not an object" + : null; + deepStrictEqual( + [ + outcome((): unknown => create("Not found")), + outcome((): unknown => create(5)), + outcome((): unknown => create(null)), + ], + options === null + ? [ + refused("`options` is not an object: Not found"), + refused("`options` is not an object: 5"), + refused("`options` is not an object: null"), + ] + : [refused(options), refused(options), refused(options)], + name, + ); + deepStrictEqual( + outcome((): unknown => create(none)), + "returned", + name, + ); + } + }); + + test("a revoked proxy, or one whose traps throw, throws the error of V8 (or of the trap itself), formatted", () => { + for (const [name, create, operation] of [ + ...optional, + [ + "new Context()", + (options: unknown): unknown => new Context(given(options)), + "get", + ], + ] as const) { + deepStrictEqual( + [ + outcome((): unknown => create(revoked())), + outcome((): unknown => create(throwing())), + ], + [revokedError(operation), ownError], + name, + ); + } + }); + + test("new Context() and shutdown() refuse options that are not an object", async () => { + deepStrictEqual( + [ + outcome((): unknown => new Context(given(null))), + outcome((): unknown => new Context(given("x"))), + outcome((): unknown => shutdown(createServer(), given("x"))), + outcome( + (): unknown => + new Context({ + request: given(revoked()), + response: given(revoked()), + }), + ), + ], + [ + refused("`options` is not an object: null"), + refused("`options` is not an object: x"), + refused("`options` is not an object: x"), + refused("`request` is not a BindenRequest: (an object)"), + ], + ); + // Its own options are optional + const server = createServer(); + await within(shutdown(server, none)); + ok(isClosing(server)); + }); + + test("new Context() refuses a request and a response that are not of binden (a proxy of one too, which no trap of tells), and a report that is not a function, described", () => { + const request = new BindenRequest(new Socket()); + const response = new BindenResponse(request); + let traps = 0; + const trap = (): never => { + traps += 1; + throw new RangeError("A trap of the test"); + }; + const create = + (options: object): (() => unknown) => + (): unknown => + new Context(given({ request, response, ...options })); + deepStrictEqual( + [ + outcome(create({ request: new Proxy(request, { has: trap }) })), + outcome( + create({ + request: Object.create( + new Proxy(BindenRequest.prototype, { getPrototypeOf: trap }), + ) as object, + }), + ), + outcome(create({ request: { id: "a", socket: null } })), + outcome(create({ request: "a" })), + outcome(create({ response: new Proxy(response, { ownKeys: trap }) })), + outcome(create({ response: Object.create(null) as object })), + outcome(create({ report: "a" })), + outcome(create({ report: Symbol("s") })), + traps, + ], + [ + refused("`request` is not a BindenRequest: (an object)"), + refused("`request` is not a BindenRequest: (an object)"), + refused("`request` is not a BindenRequest: (an object)"), + refused("`request` is not a BindenRequest: a"), + refused("`response` is not a BindenResponse: (an object)"), + refused("`response` is not a BindenResponse: (an object)"), + refused("`report` is not a function: a"), + refused("`report` is not a function: (a symbol)"), + 0, + ], + ); + ok( + new Context({ request, response, report: (): null => null }).request === + request, + ); + }); + + test("a nested option that a revoked proxy is, or whose traps throw, throws the error of V8 (or of the trap itself), formatted", () => { + for (const row of [ + [ + "Binden routing", + (value: unknown): unknown => new Binden({ routing: given(value) }), + "get", + ], + [ + "Binden access_log", + (value: unknown): unknown => new Binden({ access_log: given(value) }), + "get", + ], + [ + "Binden logger", + (value: unknown): unknown => new Binden({ logger: given(value) }), + "get", + ], + [ + "Binden allowed_hosts", + (value: unknown): unknown => + new Binden({ allowed_hosts: given(value) }), + "IsArray", + refused("`allowed_hosts` is not an array or a function"), + ], + [ + "BindenError headers", + (value: unknown): unknown => + new BindenError(400, { headers: { "X-A": given(value) } }), + "IsArray", + refused("Invalid value of the `X-A` header"), + ], + ] as const) { + const [name, create, operation, refusal = ownError] = row; + deepStrictEqual( + [ + outcome((): unknown => create(revoked())), + outcome((): unknown => create(throwing())), + ], + [revokedError(operation), refusal], + name, + ); + } + }); + + test("createServer() formats the argument errors of Node.js", () => { + const [type, , formatted] = outcome((): unknown => + new Binden().createServer({ requestTimeout: given("x") }), + ) as unknown[]; + deepStrictEqual([type, formatted], ["TypeError", true]); + }); + }, +); + +suite( + "BindenError and BindenAbortError check their arguments first (R27-1)", + () => { + test("the status of a BindenError is checked before its default message is looked up, which would convert it", () => { + conversions = 0; + deepStrictEqual( + [ + outcome((): unknown => new BindenError(given(counting("404")))), + outcome((): unknown => new BindenError(given(revoked()))), + outcome((): unknown => new BindenError(given(Symbol("s")))), + outcome((): unknown => new BindenError(500, given(revoked()))), + ], + [ + refused("Invalid status code"), + refused("Invalid status code"), + refused("Invalid status code"), + revokedError("get"), + ], + ); + deepStrictEqual(conversions, 0); + }); + + test("the values of a header of a BindenError are read once, by their indexes (an iterator of its own is not used)", () => { + const values = ["a"]; + Reflect.set(values, Symbol.iterator, function* evil(): Generator { + yield "b\r\nX-Injected: 1"; + }); + const error = new BindenError(401, { + headers: { "WWW-Authenticate": values }, + }); + deepStrictEqual(error.headers, { "WWW-Authenticate": ["a"] }); + ok(Object.isFrozen(error.headers["WWW-Authenticate"])); + }); + + test("a BindenAbortError of anything but its three reasons is refused, described", () => { + conversions = 0; + deepStrictEqual( + [ + outcome((): unknown => new BindenAbortError(given(Symbol("s")))), + outcome( + (): unknown => new BindenAbortError(given(counting("deadline"))), + ), + outcome((): unknown => new BindenAbortError(given("timeout"))), + new BindenAbortError("client_closed").reason, + new BindenAbortError("deadline").reason, + new BindenAbortError("shutdown").reason, + ], + [ + refused("Unknown abort reason: (a symbol)"), + refused("Unknown abort reason: (an object)"), + refused("Unknown abort reason: timeout"), + "client_closed", + "deadline", + "shutdown", + ], + ); + deepStrictEqual(conversions, 0); + }); + }, +); + +/** + * A logger whose proxy throws the error of the test (a new one) from one of + * its traps only: its methods are read as usual + */ +function oneTrap(trap: "get" | "getPrototypeOf" | "has" | "ownKeys"): object { + const error = new RangeError("A trap of the test"); + own = error; + const logger = { + fatal: (): null => null, + error: (): null => null, + warn: (): null => null, + info: (): null => null, + debug: (): null => null, + trace: (): null => null, + child: (): unknown => logger, + }; + return new Proxy(logger, { + [trap]: (): never => { + throw error; + }, + }); +} + +suite( + "What the checks and the copies of the options run once they are read is formatted (R28)", + () => { + test("new Binden() checks its logger inside the boundary (its methods, read), and neither it nor a Context runs another trap of a proxy to tell a logger of binden (its brand)", () => { + const context = (logger: object): unknown => + new Context({ + request: new BindenRequest(new Socket()), + response: given(null), + logger: given(logger), + }); + deepStrictEqual( + [ + outcome((): unknown => new Binden({ logger: given(oneTrap("get")) })), + outcome( + (): unknown => + new Binden({ logger: given(oneTrap("getPrototypeOf")) }), + ), + outcome((): unknown => new Binden({ logger: given(oneTrap("has")) })), + outcome( + (): unknown => new Binden({ logger: given(oneTrap("ownKeys")) }), + ), + // A Context does not read its logger: only its response is refused + outcome((): unknown => context(oneTrap("getPrototypeOf"))), + outcome((): unknown => context(revoked())), + ], + [ + ownError, + "returned", + "returned", + "returned", + refused("`response` is not a BindenResponse: null"), + refused("`response` is not a BindenResponse: null"), + ], + ); + }); + + test("the json of a BindenError is copied once by its constructor: what that runs is formatted, and its getter runs no code of the application", () => { + /** A new error of the test (see `own`) */ + const fresh = (): RangeError => { + own = new RangeError("A trap of the test"); + return own; + }; + deepStrictEqual( + [ + outcome((): unknown => { + const error = fresh(); + return new BindenError(400, { + json: { + get a(): never { + throw error; + }, + }, + }); + }), + outcome((): unknown => { + const error = fresh(); + const json = new Proxy( + { a: 1 }, + { + ownKeys: (): never => { + throw error; + }, + }, + ); + return new BindenError(400, { json }); + }), + ], + [ownError, ownError], + ); + let reads = 0; + const json: Record = { + get a(): number { + reads += 1; + return reads; + }, + }; + const created = new BindenError(400, { json, expose: true }); + json.b = 2; + const [first, second] = [created.json, created.json]; + deepStrictEqual([first, second, reads], [{ a: 1 }, { a: 1 }, 1]); + ok(first !== second); + }); + }, +); + +suite("The routing formats what checking its arguments throws (R27-1)", () => { + test("Router: a method described without converting it, a middleware that a revoked proxy is, and middlewares() of a method that is not a string", () => { + conversions = 0; + const router = new Router().get((): void => { + // Nothing + }); + deepStrictEqual( + [ + outcome((): unknown => + new Router().on(given(counting("GET")), new Noop()), + ), + outcome((): unknown => new Router().on("GET", given(revoked()))), + outcome((): unknown => new Router().on("GET", given(throwing()))), + outcome((): unknown => router.middlewares(given(5))), + outcome((): unknown => router.middlewares(given(counting("GET")))), + router.middlewares("get").length, + ], + [ + refused("Method (an object) is not a valid token"), + revokedError("getPrototypeOf"), + ownError, + refused("The method is not a string: 5"), + refused("The method is not a string: (an object)"), + 1, + ], + ); + deepStrictEqual(conversions, 0); + }); + + test("a stack: use(), route(), mount() and off() of a revoked proxy, or one whose traps throw", () => { + for (const [name, call] of [ + ["use()", (item: unknown): unknown => new Binden().use(given(item))], + [ + "use(path)", + (item: unknown): unknown => new Binden().use("/", given(item)), + ], + [ + "route()", + (item: unknown): unknown => new Binden().route("/", given(item)), + ], + [ + "mount()", + (item: unknown): unknown => new Binden().mount("/", given(item)), + ], + ["off()", (item: unknown): unknown => new Binden().off(given(item))], + ] as const) { + deepStrictEqual( + [ + outcome((): unknown => call(revoked())), + outcome((): unknown => call(throwing())), + ], + [revokedError("getPrototypeOf"), ownError], + name, + ); + } + }); +}); + +suite("A request formats what its methods and getters throw (R27-1)", () => { + /** A request of the application, with headers */ + function requestOf(app: Binden = new Binden()): BindenRequest { + const { IncomingMessage: Request } = app.serverOptions(); + const request = new Request(new Socket()); + request.headers = { host: "example.com", accept: "text/html" }; + request.url = "/"; + request.method = "GET"; + return request; + } + + test("new BindenRequest() of a socket that a revoked proxy is, or whose getter throws (as Node.js reads it)", () => { + deepStrictEqual( + [ + outcome((): unknown => new BindenRequest(given(revoked()))), + outcome((): unknown => new BindenRequest(given(throwing()))), + outcome((): unknown => new BindenRequest(given(none))), + ], + [revokedError("get"), ownError, "returned"], + ); + }); + + test("header() of a name that is not a string, and accepts() of an offer that is not one, described without converting them", () => { + conversions = 0; + const request = requestOf(); + deepStrictEqual( + [ + outcome((): unknown => request.header(given(5))), + outcome((): unknown => request.header(given(counting("host")))), + outcome((): unknown => request.accepts(given(counting("text/html")))), + outcome((): unknown => request.acceptsLanguages("en", given(null))), + request.header("Host"), + request.accepts("text/html"), + ], + [ + refused("The name is not a string: 5"), + refused("The name is not a string: (an object)"), + refused("Invalid media type: (an object)"), + refused("Invalid language tag: null"), + "example.com", + "text/html", + ], + ); + deepStrictEqual(conversions, 0); + }); + + test("a getter formats what a function of the application throws (e.g. `allowed_hosts` of URL, host and query)", () => { + const request = requestOf( + new Binden({ + allowed_hosts: (): boolean => { + throw own; + }, + }), + ); + for (const name of ["URL", "host", "query"] as const) { + own = new RangeError("A trap of the test"); + deepStrictEqual( + outcome((): unknown => request[name]), + ownError, + name, + ); + } + }); + + test("every method and getter that binden defines formats what reading the request throws (e.g. its headers or its socket, replaced by the application)", () => { + const trusting = new Binden({ request_id: { trust_incoming: true } }); + for (const [name, read, replaced, app] of [ + ["accepts()", (r: BindenRequest): unknown => r.accepts("a/b"), "headers"], + [ + "acceptsCharsets()", + (r: BindenRequest): unknown => r.acceptsCharsets("utf-8"), + "headers", + ], + [ + "acceptsEncodings()", + (r: BindenRequest): unknown => r.acceptsEncodings("gzip"), + "headers", + ], + [ + "acceptsLanguages()", + (r: BindenRequest): unknown => r.acceptsLanguages("en"), + "headers", + ], + ["header()", (r: BindenRequest): unknown => r.header("a"), "headers"], + [ + "accept_encoding", + (r: BindenRequest): unknown => r.accept_encoding, + "headers", + ], + [ + "authorization", + (r: BindenRequest): unknown => r.authorization, + "headers", + ], + [ + "content_encoding", + (r: BindenRequest): unknown => r.content_encoding, + "headers", + ], + [ + "content_type", + (r: BindenRequest): unknown => r.content_type, + "headers", + ], + ["cookies", (r: BindenRequest): unknown => r.cookies, "headers"], + ["forwarded", (r: BindenRequest): unknown => r.forwarded, "headers"], + [ + "if_modified_since", + (r: BindenRequest): unknown => r.if_modified_since, + "headers", + ], + ["range", (r: BindenRequest): unknown => r.range, "headers"], + ["host", (r: BindenRequest): unknown => r.host, "headers"], + ["URL", (r: BindenRequest): unknown => r.URL, "headers"], + ["query", (r: BindenRequest): unknown => r.query, "headers"], + ["ip", (r: BindenRequest): unknown => r.ip, "socket"], + ["ips", (r: BindenRequest): unknown => r.ips, "socket"], + ["protocol", (r: BindenRequest): unknown => r.protocol, "socket"], + ["secure", (r: BindenRequest): unknown => r.secure, "socket"], + ["id", (r: BindenRequest): unknown => r.id, "headersDistinct", trusting], + ] as const) { + const request = requestOf(app); + const error = new RangeError("A trap of the test"); + own = error; + Object.defineProperty(request, replaced, { + configurable: true, + get: (): never => { + throw error; + }, + }); + deepStrictEqual( + outcome((): unknown => read(request)), + ownError, + name, + ); + } + }); + + test("an allowed host that is not one is described, never converted", () => { + conversions = 0; + deepStrictEqual( + outcome((): unknown => + validateRequestOptions({ allowed_hosts: [given(counting("a.test"))] }), + ), + refused("`allowed_hosts` has an invalid host: (an object)"), + ); + deepStrictEqual(conversions, 0); + }); +}); + +suite("The servers of binden format what their arguments throw (R27-1)", () => { + test("callback() of a revoked proxy, and its listener of values that are not a request and a response", () => { + const server = createServer(); + const listener = new Binden().callback(server); + deepStrictEqual( + [ + outcome((): unknown => new Binden().callback(given(revoked()))), + outcome((): void => { + listener(given(revoked()), given(revoked())); + }), + ], + [revokedError("get"), revokedError("get")], + ); + }); + + test("the listener of callback() refuses a proxy of a request or a response of the application (whose traps would run later), logged and its connection closed", async () => { + const errors: string[] = []; + const logger = { + fatal: (): null => null, + error: (message: string): void => { + errors.push(message); + }, + warn: (): null => null, + info: (): null => null, + debug: (): null => null, + trace: (): null => null, + child: (): unknown => logger, + }; + const app = new Binden({ logger: given(logger) }); + const listener = app.callback(createServer()); + const { IncomingMessage: Request, ServerResponse: Response } = + app.serverOptions(); + const trap = (): never => { + throw new RangeError("A trap of the test"); + }; + const destroyed: boolean[] = []; + for (const proxied of ["request", "response"]) { + const socket = new Socket(); + const request = new Request(socket); + const response = new Response(request); + listener( + proxied === "request" + ? new Proxy(request, { ownKeys: trap, has: trap }) + : request, + proxied === "response" + ? new Proxy(response, { ownKeys: trap, has: trap }) + : response, + ); + destroyed.push(socket.destroyed); + } + await new Promise((resolve) => { + setImmediate(resolve); + }); + deepStrictEqual( + [destroyed, errors], + [ + [true, true], + [ + "The request and the response are not those of the application (`serverOptions()`)", + "The request and the response are not those of the application (`serverOptions()`)", + ], + ], + ); + }); + + test("shutdown() of a revoked proxy, and the rejection of a value registered as a server that is not one (after the first await too)", async () => { + const fake = Object.assign(new EventEmitter(), { + listen: (): unknown => fake, + listening: false, + }); + deepStrictEqual( + [ + outcome((): unknown => shutdown(given(revoked()))), + await rejection(async (): Promise => + shutdown(given(fake)), + ), + isClosing(given(revoked())), + ], + [ + revokedError("get"), + [ + "TypeError", + "server.closeIdleConnections is not a function", + true, + false, + ], + false, + ], + ); + }); + + test("the listen() of a server registered for shutdown() formats what Node.js throws for its arguments", async () => { + const server = createServer(); + const closing = shutdown(server, { timeout: 1 }); + deepStrictEqual( + [ + outcome((): unknown => server.listen(given(Symbol("port")))), + // Without its message, which tells the trap that Node.js runs first + // (`get` or `getPrototypeOf`, by its version) + ( + outcome((): unknown => server.listen(given(revoked()))) as unknown[] + ).filter((_, index) => index !== 1), + outcome((): unknown => server.listen(given(throwing()))), + ], + [ + [ + "TypeError", + "The argument 'options' is invalid. Received { port: Symbol(port) }", + true, + false, + ], + ["TypeError", true, false], + ownError, + ], + ); + // Never bound: closed at once + await within(closing); + }); + + test("the serializer of a context tells the type of a value that a revoked proxy is, without throwing", () => { + deepStrictEqual( + [serializers.context(revoked()), createSerializers().context(throwing())], + [{ type: "object" }, { type: "object" }], + ); + }); + + test("the serializer of a context tells the type of a proxy of a context, and of an object of the prototype of Context (neither has its brand)", () => { + const request = new BindenRequest(new Socket()); + const context = new Context({ + request, + response: new BindenResponse(request), + }); + const trap = (): never => { + throw new RangeError("A trap of the test"); + }; + deepStrictEqual( + [ + serializers.context(new Proxy(context, { ownKeys: trap })), + createSerializers().context(new Proxy(context, { has: trap })), + serializers.context(context).id, + serializers.context(Object.create(Context.prototype)), + ], + [{ type: "object" }, { type: "object" }, request.id, { type: "object" }], + ); + }); + + test("framingLength() refuses a value that is neither a number nor a string (also an element), before it converts it, and formats what reading it throws", () => { + conversions = 0; + deepStrictEqual( + [ + outcome((): unknown => framingLength(given(revoked()))), + outcome((): unknown => framingLength(given(Symbol("s")))), + outcome((): unknown => framingLength(given(counting("5")))), + outcome((): unknown => framingLength(given(["5", counting("5")]))), + framingLength("5"), + framingLength(5), + framingLength(["5", "5"]), + framingLength(["4", "5"]), + framingLength([]), + framingLength(none), + ], + [ + revokedError("IsArray"), + refused("Invalid Content-Length: (a symbol)"), + refused("Invalid Content-Length: (an object)"), + refused("Invalid Content-Length: (an object)"), + 5, + 5, + 5, + null, + null, + null, + ], + ); + deepStrictEqual(conversions, 0); + }); + + test("formatting() formats what its function throws (also when it is not one), and described() never converts", () => { + conversions = 0; + own = new RangeError("A trap of the test"); + deepStrictEqual( + [ + outcome((): unknown => formatting(given(5))), + outcome((): unknown => + formatting(() => { + throw own; + }), + ), + described(counting()), + described(5n), + ], + [ + ["TypeError", "run is not a function", true, false], + ownError, + "(an object)", + "5n", + ], + ); + deepStrictEqual(conversions, 0); + }); +}); diff --git a/packages/binden/test/error-cookies.test.ts b/packages/binden/test/error-cookies.test.ts new file mode 100644 index 00000000..e877ed1f --- /dev/null +++ b/packages/binden/test/error-cookies.test.ts @@ -0,0 +1,166 @@ +import { Binden, type Context, Cookie, type IBindenOptions } from "../index.js"; +import { capture, listen } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { converse } from "./connection.js"; + +/** A cookie whose `toString()` throws from its second call on */ +function failingLater(): object { + let calls = 0; + return { + toString(): string { + calls += 1; + if (calls > 1) { + throw new Error("Failed later"); + } + return "later=1"; + }, + }; +} + +/** The cookies (but the valid one) that fail whenever they are serialised */ +const failing: Record object> = { + throwing: () => ({ + toString(): never { + throw new Error("Failed"); + }, + }), + "revoked proxy": () => { + const { proxy, revoke } = Proxy.revocable( + new Cookie({ key: "r", value: "1" }), + {}, + ); + revoke(); + return proxy; + }, + derived: () => Object.create(new Cookie({ key: "d", value: "1" })) as object, + "null prototype": () => Object.create(null) as object, + symbol: () => ({ toString: (): unknown => Symbol("s") }), + "line break": () => ({ toString: (): string => "b=1\r\nX-Injected: 1" }), +}; + +/** The error of each of them (its type and message), as it is logged */ +const errors: Record = { + throwing: ["Error", "Failed"], + "revoked proxy": [ + "TypeError", + "Cannot perform 'get' on a proxy that has been revoked", + ], + derived: [ + "TypeError", + "Cannot read private member #key from an object whose class did not declare it", + ], + "null prototype": ["TypeError", "cookie.toString is not a function"], + symbol: ["TypeError", "Cannot convert a Symbol value to a string"], + "line break": [ + "TypeError", + 'Invalid character in header content ["Set-Cookie"]', + ], +}; + +/** A request that fails, and the pipelined one after it */ +const requests = + "GET /fail HTTP/1.1\r\nHost: localhost\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + +suite("The cookies of an error reply of binden", () => { + test("each cookie that fails as its head is committed is dropped and logged (also one that first fails then): the reply is sent with the others, framed, and the pipelined request is answered (R3 P2-4)", async () => { + const valid = new Cookie({ key: "a", value: "1" }); + const handlers: Record = { + default: {}, + // Its reply fails as well: the empty `500` of binden replaces it + "failing error_handler": { + error_handler: async ({ response }: Context): Promise => { + await response.status(503).send("handled"); + }, + }, + }; + for (const [handler, options] of Object.entries(handlers)) { + for (const [shape, make] of Object.entries(failing)) { + const name = `${handler}, ${shape}`; + const captured = capture(); + const rejections: unknown[] = []; + let kept: ReadonlySet = new Set(); + // A valid cookie, read once by each commit + let conversions = 0; + const counted = { + toString(): string { + conversions += 1; + return "c=1"; + }, + }; + const app = new Binden({ + ...options, + logger: captured.logger, + log_error_details: true, + }); + app.use(async ({ response, url }: Context) => { + if (url.pathname === "/next") { + await response.send("next"); + return; + } + // The second fails later, once the reply of the application has + // failed by the third + for (const cookie of [counted, failingLater(), make(), valid]) { + response.cookies.add(cookie as Cookie); + } + kept = response.cookies; + try { + await response.send("x"); + } catch (error: unknown) { + // The reply of the application is not guarded + rejections.push(error); + throw error; + } + }); + const listening = await listen(app); + try { + const { received, closed } = await converse(listening.port, requests); + ok(closed, name); + const [first = "", second = ""] = received + .split(/(?=HTTP\/1\.1 )/u) + .map((reply) => reply.replaceAll(/\r\nDate: [^\r]*/gu, "")); + deepStrictEqual( + first, + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nSet-Cookie: c=1\r\nSet-Cookie: a=1; Path=/; HttpOnly; SameSite=Lax\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\n", + name, + ); + ok(second.startsWith("HTTP/1.1 200 OK\r\n"), `${name}: ${second}`); + ok(second.endsWith("\r\n\r\nnext"), `${name}: ${second}`); + } finally { + await listening.close(); + } + deepStrictEqual(rejections.length, 1, name); + // Dropped from its cookies + deepStrictEqual([...kept], [counted, valid], name); + // By the reply of the application, of the `error_handler`, and of + // binden + deepStrictEqual(conversions, handler === "default" ? 2 : 3, name); + const dropped = captured.records.filter( + ({ msg }) => + msg === "A cookie has not been sent with the error reply", + ); + deepStrictEqual( + dropped.map(({ level, failure }) => { + const { location, status, type, message } = failure as Record< + string, + unknown + >; + return [level, location, status, type, message]; + }), + [ + [40, "send", 500, "Error", "Failed later"], + [40, "send", 500, ...errors[shape]], + ], + name, + ); + deepStrictEqual( + captured.records.filter( + ({ msg }) => msg === "The error reply has not been sent", + ), + [], + name, + ); + } + } + }); +}); diff --git a/packages/binden/test/error-retention.test.ts b/packages/binden/test/error-retention.test.ts new file mode 100644 index 00000000..25b9276a --- /dev/null +++ b/packages/binden/test/error-retention.test.ts @@ -0,0 +1,99 @@ +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { formatStack } from "../src/exchange.js"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs the fixture of the errors that binden hands over in a process of its + * own, whose collections the test controls (`--expose-gc`): whether the + * responses are collected while the sources and the application keep them + */ +function retained(kind: string, mode: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "error-retention.ts"), + kind, + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite("An error that binden creates and hands over", () => { + for (const [kind, mode, how, kept] of [ + [ + "like", + "deadline", + "the reason of the deadline, which a stream-like source keeps", + "BindenAbortError", + ], + [ + "native", + "deadline", + "the reason of the deadline, which the send of a Readable rejects with", + "BindenAbortError", + ], + [ + "like", + "gone", + "the reason of a client gone away, which a stream-like source keeps", + "BindenAbortError, Error ERR_STREAM_PREMATURE_CLOSE", + ], + [ + "native", + "gone", + "the premature close that the send rejects with once the client has gone away", + "Error ERR_STREAM_PREMATURE_CLOSE", + ], + [ + "like", + "mismatch", + "the mismatch of a stream-like body shorter than its Content-Length", + "Error ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + [ + "native", + "mismatch", + "the mismatch of a body shorter than its Content-Length", + "Error ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + [ + "native", + "refused", + "the refusal of a pipe, which fails its work", + "TypeError", + ], + ] as const) { + test(`does not keep the response: ${how}`, () => { + // 60 requests, whose errors are kept + deepStrictEqual(retained(kind, mode), { + status: 0, + stdout: `collected ${kept}\n`, + stderr: "", + }); + }); + } + + test("is handed over as it is when a `prepareStackTrace` of the application throws: its stack is left to it", () => { + const prepare: unknown = Reflect.get(Error, "prepareStackTrace"); + const error = new Error("unformatted"); + Reflect.set(Error, "prepareStackTrace", (): never => { + throw new Error("prepareStackTrace"); + }); + try { + strictEqual(formatStack(error), error); + } finally { + Reflect.set(Error, "prepareStackTrace", prepare); + } + // Formatted once it is read again + const { stack = "" } = error; + ok(stack.startsWith("Error: unformatted\n"), stack); + }); +}); diff --git a/packages/binden/test/error.test.ts b/packages/binden/test/error.test.ts new file mode 100644 index 00000000..da684902 --- /dev/null +++ b/packages/binden/test/error.test.ts @@ -0,0 +1,181 @@ +import { BindenError, type IBindenErrorOptions } from "../index.js"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { suite, test } from "node:test"; +import { STATUS_CODES } from "node:http"; + +suite("BindenError", () => { + test("constructor", () => { + const expose = false; + const message = "Something bad happened"; + const status = 500; + const error = new BindenError(status, { message }); + deepStrictEqual(error.expose, expose); + deepStrictEqual(error.status, status); + deepStrictEqual(error.json, null); + deepStrictEqual(error.message, message); + deepStrictEqual(error.name, "BindenError"); + ok(typeof error.cause === "undefined"); + }); + + test("constructor (with `cause`)", () => { + const expose = false; + const message = "Something bad happened"; + const status = 500; + const cause = new Error("Socket has been closed"); + const error = new BindenError(status, { message, cause }); + deepStrictEqual(error.expose, expose); + deepStrictEqual(error.status, status); + deepStrictEqual(error.json, null); + deepStrictEqual(error.message, message); + deepStrictEqual(error.name, "BindenError"); + deepStrictEqual(error.cause, cause); + }); + + test("constructor (json)", () => { + const json = { error: "Something bad happened" }; + const expose = true; + const status = 400; + const error = new BindenError(status, { json, expose }); + deepStrictEqual(error.expose, expose); + deepStrictEqual(error.status, status); + deepStrictEqual(error.json, json); + deepStrictEqual(error.message, STATUS_CODES[status]); + deepStrictEqual(error.name, "BindenError"); + }); + + test("constructor (with invalid status)", () => { + throws( + () => new BindenError(300), + new RangeError("Status code is less than 400"), + ); + throws( + () => new BindenError(600), + new RangeError("Status code is greater than 599"), + ); + // 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 (`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 (`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 (invalid `expose`, `json` and `message`)", () => { + const invalid: [IBindenErrorOptions, string][] = [ + [{ expose: "false" as unknown as boolean }, "`expose` is not a boolean"], + [{ expose: 1 as unknown as boolean }, "`expose` is not a boolean"], + [ + { + expose: true, + json: [{ field: "name" }] as unknown as Record, + }, + "`json` is not a plain object", + ], + [ + { json: "abc" as unknown as Record }, + "`json` is not a plain object", + ], + [ + { json: null as unknown as Record }, + "`json` is not a plain object", + ], + [ + { json: new Map() as unknown as Record }, + "`json` is not a plain object", + ], + [{ message: 42 as unknown as string }, "`message` is not a string"], + [{ message: null as unknown as string }, "`message` is not a string"], + ]; + for (const [options, message] of invalid) { + throws(() => new BindenError(400, options), new TypeError(message)); + } + // A plain object without a prototype, and the defaults + const json = Object.assign(Object.create(null) as object, { a: 1 }); + deepStrictEqual(new BindenError(400, { json, expose: true }).json, { + a: 1, + }); + // Options that are not set + const unset: unknown = Reflect.get({}, "unset"); + const defaults = new BindenError(400, { + expose: unset, + json: unset, + message: unset, + } as IBindenErrorOptions); + deepStrictEqual( + [defaults.expose, defaults.json, defaults.message], + [false, null, "Bad Request"], + ); + }); + + test("constructor (an unregistered status code)", () => { + const error = new BindenError(499); + deepStrictEqual(error.status, 499); + deepStrictEqual(error.message, ""); + }); +}); diff --git a/packages/binden/test/errors.test.ts b/packages/binden/test/errors.test.ts new file mode 100644 index 00000000..fb9e24c1 --- /dev/null +++ b/packages/binden/test/errors.test.ts @@ -0,0 +1,298 @@ +import { + Binden, + BindenError, + type Context, + type IBindenOptions, + Middleware, +} from "../index.js"; +import { + type ICaptured, + type IListening, + capture, + listen, + send, +} from "./helpers.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; + +/** 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 removes the headers of the body that it replaces", async () => { + // Its framing, representation and validators: e.g. a `Trailer` that + // Node.js refuses with a `Content-Length`, or a stale length + const headers = { + "Content-Digest": "sha-256=:AAAA:", + "Content-Disposition": "attachment", + "Content-Encoding": "gzip", + "Content-Language": "en", + "Content-Length": 999, + "Content-Location": "/page.html", + "Content-Range": "bytes 0-1/2", + "Content-Type": "text/html", + Digest: "SHA-256=AAAA", + ETag: `"x"`, + "Last-Modified": "Wed, 01 Jan 2020 00:00:00 GMT", + "Repr-Digest": "sha-256=:AAAA:", + Trailer: "X-T", + "Transfer-Encoding": "chunked", + }; + for (const expose of [false, true]) { + await withApp( + {}, + (app) => { + app.use(({ response }: Context) => { + response.set(headers); + throw new BindenError(409, { message: "conflict", expose }); + }); + }, + async ({ port }) => { + const response = await send(port); + deepStrictEqual(response.status, 409); + deepStrictEqual(response.body, expose ? "conflict" : ""); + const own: Partial> = { + "Content-Length": `${response.body.length}`, + ...(expose ? { "Content-Type": "text/plain; charset=utf-8" } : {}), + }; + for (const name of Object.keys(headers)) { + deepStrictEqual( + response.headers[name.toLowerCase()], + own[name], + `${name} (expose: ${expose})`, + ); + } + }, + ); + } + }); + + 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 new file mode 100644 index 00000000..77bba661 --- /dev/null +++ b/packages/binden/test/exchange.test.ts @@ -0,0 +1,575 @@ +/* eslint-disable init-declarations */ +import { type AddressInfo, Socket } from "node:net"; +import { + BindenAbortError, + BindenRequest, + BindenResponse, + Context, +} from "../index.js"; +import { IncomingMessage, type Server, createServer, get } from "node:http"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; +import { Exchange } from "../src/exchange.js"; +import { PassThrough } from "node:stream"; +import { once } from "node:events"; +import { within } from "./helpers.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({ host: "127.0.0.1", 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, "127.0.0.1"); + 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", + ], + ); + // Without the details of the errors + deepStrictEqual( + errors.map(([, extra]) => extra?.failure), + [ + { type: "Error", location: "cleanup" }, + { type: "Error", location: "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; + let reported = false; + + context.onCleanup(() => { + ran = true; + }); + context.onCleanup(() => { + throw new Error("A failing cleanup"); + }); + + 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 () => { + 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("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("its tokens stay issued once revoked, those of another exchange never are", async () => { + const [request, response] = await exchange(); + const exchangeOf = Exchange.for(request, response); + const token = exchangeOf.token(); + ok(exchangeOf.issued(token)); + exchangeOf.seal(); + ok(exchangeOf.issued(token)); + ok(!exchangeOf.issued({})); + response.end(); + }); + + 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, + ); + }); +}); + +suite("The connection of an exchange", () => { + /** A request on a connection (a socket of its own), and its response */ + const detached = ( + socket = new Socket(), + ): { socket: Socket; request: IncomingMessage; response: BindenResponse } => { + const request = new IncomingMessage(socket); + return { socket, request, response: new BindenResponse(request) }; + }; + + /** + * Gives a response a socket of its own, which closes it once destroyed + * (on every Node.js, unlike a response without one) + */ + const assigned = (response: BindenResponse): BindenResponse => { + response.assignSocket(new Socket()); + return response; + }; + + test("a response that waits for its socket is closed once, when the connection closes", async () => { + const { socket, request, response } = detached(); + const exchange = Exchange.for(request, response); + let cleaned = 0; + exchange.onCleanup(() => { + cleaned += 1; + }); + const closes: unknown[] = []; + response.on("close", () => { + closes.push(response.destroyed); + }); + ok(!exchange.terminating); + socket.emit("close"); + ok(exchange.terminating); + socket.emit("close"); + await within(exchange.finished); + deepStrictEqual([cleaned, closes], [1, [true]]); + ok(response.closed); + deepStrictEqual( + (exchange.signal.reason as BindenAbortError).reason, + "client_closed", + ); + }); + + test("a response whose socket never closes it is closed by the framework", async () => { + const { socket, request, response } = detached(); + const exchange = Exchange.for(request, response); + // A socket whose destroy does not close it (as Node.js 24.14 leaves a + // response that has no socket) + const lasting = new PassThrough(); + lasting.destroy = (): PassThrough => lasting; + response.assignSocket(lasting as unknown as Socket); + socket.emit("close"); + await within(exchange.finished); + ok(response.closed && response.destroyed); + }); + + test("its responses share one listener of the connection, removed once they are closed", async () => { + const socket = new Socket(); + const before = socket.listenerCount("close"); + const first = detached(socket); + const second = detached(socket); + const one = Exchange.for(first.request, first.response); + const two = Exchange.for(second.request, second.response); + deepStrictEqual(socket.listenerCount("close"), before + 1); + assigned(first.response).destroy(); + await within(one.finished); + deepStrictEqual(socket.listenerCount("close"), before + 1); + assigned(second.response).destroy(); + await within(two.finished); + deepStrictEqual(socket.listenerCount("close"), before); + // A new exchange of the connection observes it again + const third = detached(socket); + const exchange = Exchange.for(third.request, third.response); + deepStrictEqual(socket.listenerCount("close"), before + 1); + socket.emit("close"); + await within(exchange.finished); + deepStrictEqual(socket.listenerCount("close"), before); + }); + + test("a request without a socket has no connection to observe", async () => { + const request = new IncomingMessage(null as unknown as Socket); + const response = new BindenResponse(request); + const exchange = Exchange.for(request, response); + assigned(response).destroy(); + await within(exchange.finished); + ok(!exchange.terminating); + }); + + test("a shutdown terminates it before its abort listeners run", () => { + const { request, response } = detached(); + const exchange = Exchange.for(request, response); + const seen: boolean[] = []; + exchange.signal.addEventListener("abort", () => { + seen.push(exchange.terminating); + }); + ok(exchange.abort("shutdown")); + deepStrictEqual(seen, [true]); + // Not another reason + const other = detached(); + const deadline = Exchange.for(other.request, other.response); + ok(deadline.abort("deadline")); + ok(!deadline.terminating); + }); +}); diff --git a/packages/binden/test/fixtures/async-logger.ts b/packages/binden/test/fixtures/async-logger.ts new file mode 100644 index 00000000..1f69c2f4 --- /dev/null +++ b/packages/binden/test/fixtures/async-logger.ts @@ -0,0 +1,64 @@ +// Run by `process.test.ts`: a logger whose methods (and `child()`) return +// rejected promises must neither crash the process (`unhandledRejection`) nor +// keep the requests from being handled, whoever logs (the framework or a +// context): a `child()` that returns a promise gives its parent +import { Binden, BindenError } from "../../index.js"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import type { ILogger } 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 reject = (): Promise => + Promise.reject(new Error("A rejecting logger")); +const logger: ILogger = { + fatal: reject, + error: reject, + warn: reject, + info: reject, + debug: reject, + trace: reject, + // E.g. an `async` function: not a logger + child: reject as unknown as ILogger["child"], +}; + +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, "127.0.0.1"); +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, host: "127.0.0.1", 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/fixtures/carrier-stacks.ts b/packages/binden/test/fixtures/carrier-stacks.ts new file mode 100644 index 00000000..460bf008 --- /dev/null +++ b/packages/binden/test/fixtures/carrier-stacks.ts @@ -0,0 +1,337 @@ +// Run by `carrier-stacks.test.ts`, without a handler of the process: a source +// brings a value (`V`) whose `stack` is a getter of its own, which Node.js +// cannot read safely, so that binden destroys a readable with a carrier of it, +// while a `prepareStackTrace` of the application is armed: for every error +// that V8 formats meanwhile, it redefines its `stack` as a getter that throws +// and cannot be configured (`nonconf`), or freezes it and throws (`freeze`). +// The source (the other arguments): a stream-like source of its own that emits +// V (`emit`), or whose `pause()` (once the response is full, `pause`) or +// `resume()` (`resume`) throws it; a Node.js `Readable` whose own `pause()` +// throws it (`node-pause`); a `PassThrough` of `readable-stream` 4 or 3 that +// the application destroys with V after a first chunk (`rs4`, `rs3`); or a +// Node.js `Readable` whose own `resume()` throws V and whose `_destroy()` +// redefines the `stack` of what it is destroyed with as a getter that throws +// before it calls back on a later turn (`redefine`: Node.js reads that stack +// again then). The send must reject with V, the source must be destroyed +// once, and nothing may be thrown into its events. Prints, for the kind and +// each way, whether the response is cut or replied (with the status that the +// client gets), how the send has settled, what the source has been +// destroyed with (V, its carrier, or nothing: `none`), whether it is +// destroyed, and what its events have thrown. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { Readable } from "node:stream"; +import { createRequire } from "node:module"; +import { pollUntil } from "../helpers.js"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const require = createRequire(import.meta.url); +const [kind = "nonconf", ...ways] = process.argv.slice(2); + +/** Whether the `prepareStackTrace` of the application acts on what it formats */ +let armed = false; +Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + if (armed && kind === "freeze") { + Object.freeze(error); + throw new Error("Frozen"); + } else if (armed) { + try { + Object.defineProperty(error, "stack", { + get(): never { + throw new Error("Its stack cannot be read"); + }, + }); + } catch { + // E.g. frozen: left as it is + } + } + return [`${error.name}: ${error.message}`, ...frames.map(String)].join( + "\n at ", + ); + }, +); + +/** A value whose `stack` is a getter of its own (`V`) */ +function unsafe(): Error { + const value = new Error("V"); + Object.defineProperty(value, "stack", { + configurable: true, + get(): string { + return "Error: V (its own getter)"; + }, + }); + return value; +} + +/** What a source has been destroyed with */ +function described(error: unknown, value: Error): string { + return error === value + ? "V" + : (error as Error | undefined)?.cause === value + ? "carrier" + : typeof error === "undefined" || error === null + ? "none" + : String((error as Error).message); +} + +/** The message of what has been thrown, read safely */ +function messageOf(error: unknown): string { + try { + return String((error as Error).message); + } catch { + return "?"; + } +} + +/** A source, its value, and what it records */ +interface ISourceOf { + readonly source: object; + readonly value: Error; + readonly destroys: string[]; + readonly threw: string[]; + /** Brings the value (a timer of its own), once the send listens */ + bring(): void; + /** Whether it is destroyed */ + destroyed(): boolean; +} + +/** A chunk that fills the response once the client no longer reads */ +const big = Buffer.alloc(1 << 20, 0x61); + +/** A stream-like source of its own, which brings its value in its way */ +function streamLike(way: string): ISourceOf { + const value = unsafe(); + const destroys: string[] = []; + const threw: string[] = []; + let thrown = false; + /** Whether it has thrown its value (read by its loop, which it ends) */ + const isThrown = (): boolean => thrown; + let destroyed = false; + const throwing = (method: string): void => { + if (way === method && !thrown) { + thrown = true; + armed = true; + throw value; + } + }; + const source = Object.assign(new EventEmitter(), { + pause(): void { + throwing("pause"); + }, + resume(): void { + throwing("resume"); + }, + destroy(error?: unknown): void { + destroys.push(described(error, value)); + destroyed = true; + }, + }); + const fire = (event: string, chunk: unknown): void => { + try { + source.emit(event, chunk); + } catch (error: unknown) { + threw.push(messageOf(error)); + } + }; + return { + source, + value, + destroys, + threw, + bring(): void { + setTimeout(() => { + if (way === "emit") { + armed = true; + fire("error", value); + } + // Until the response is full, and its `pause()` throws + if (way === "pause") { + for (let chunk = 0; chunk < 64 && !isThrown(); chunk += 1) { + fire("data", big); + } + } + }, 10); + }, + destroyed: (): boolean => destroyed, + }; +} + +/** A Node.js `Readable` whose own `pause()` or `resume()` throws its value */ +function readable(way: string): ISourceOf { + const value = unsafe(); + const destroys: string[] = []; + const threw: string[] = []; + let thrown = false; + /** Whether it has thrown its value (read by its loop, which it ends) */ + const isThrown = (): boolean => thrown; + const source = new Readable({ + read(): void { + // Pushed by `bring()` + }, + destroy(error, callback): void { + if (way !== "redefine") { + destroys.push(described(error, value)); + callback(error); + return; + } + try { + Object.defineProperty(error, "stack", { + configurable: true, + get(): never { + throw new Error("Redefined by its _destroy()"); + }, + }); + destroys.push(`${described(error, value)}:redefined`); + } catch { + destroys.push(`${described(error, value)}:refused`); + } + setImmediate(callback, error); + }, + }); + const method = way === "redefine" ? "resume" : "pause"; + const original: unknown = Reflect.get(Readable.prototype, method); + Object.defineProperty(source, method, { + configurable: true, + value(this: Readable): Readable { + if (!thrown) { + thrown = true; + armed = true; + throw value; + } + return Reflect.apply(original as () => Readable, this, []); + }, + }); + return { + source, + value, + destroys, + threw, + bring(): void { + setTimeout(() => { + // Until the response is full, and its `pause()` throws + if (way !== "redefine") { + for (let chunk = 0; chunk < 64 && !isThrown(); chunk += 1) { + try { + source.push(big); + } catch (error: unknown) { + threw.push(messageOf(error)); + } + } + } + }, 10); + }, + destroyed: (): boolean => source.destroyed, + }; +} + +/** A `PassThrough` of `readable-stream` that the application destroys with its value */ +function passThrough(way: string): ISourceOf { + const { PassThrough } = require( + way === "rs3" ? "readable-stream-v3" : "readable-stream-v4", + ) as { + PassThrough: new () => EventEmitter & { + destroyed: boolean; + write(chunk: string): boolean; + destroy(error?: unknown): unknown; + }; + }; + const value = unsafe(); + const destroys: string[] = []; + const threw: string[] = []; + const source = new PassThrough(); + const destroy = source.destroy.bind(source); + source.destroy = (error?: unknown): unknown => { + destroys.push(described(error, value)); + return destroy(error); + }; + return { + source, + value, + destroys, + threw, + bring(): void { + setTimeout(() => { + source.write("ab"); + setTimeout(() => { + armed = true; + try { + source.destroy(value); + } catch (error: unknown) { + threw.push(messageOf(error)); + } + }, 10); + }, 10); + }, + destroyed: (): boolean => source.destroyed, + }; +} + +const sources = new Map(); +const outcomes = new Map(); +const app = new Binden().use(async (context: Context) => { + const way = context.url.pathname.slice(1); + const of = way.startsWith("rs") + ? passThrough(way) + : way === "node-pause" || way === "redefine" + ? readable(way) + : streamLike(way); + sources.set(way, of); + of.bring(); + const outcome = await context.send(of.source as Readable).then( + () => "resolved", + (error: unknown) => + `rejected:${error === of.value ? "V" : messageOf(error)}`, + ); + armed = false; + // A response whose headers are committed is cut (what the client has read + // of it then depends on the system), otherwise replied + outcomes.set( + way, + `${context.response.headersSent ? "cut" : "replied"} ${outcome}`, + ); + throw new Error(outcome); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const lines: string[] = []; +for (const way of ways) { + const socket = connect(port, "127.0.0.1"); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + socket.on("error", () => null); + const closed = once(socket, "close"); + socket.write(`GET /${way} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`); + if (way === "pause" || way === "node-pause") { + // The client reads nothing until the send has settled: the response fills + socket.pause(); + } + await pollUntil(() => outcomes.has(way)); + socket.resume(); + await closed; + const of = sources.get(way) as ISourceOf; + // Its destroy (e.g. a `_destroy()` that calls back on a later turn) + await pollUntil(() => of.destroyed()); + await new Promise(setImmediate); + const outcome = String(outcomes.get(way)); + const status = outcome.startsWith("cut") + ? "" + : ` ${/^HTTP\/1\.1 (?\d{3})/u.exec(received)?.groups?.code ?? "none"}`; + lines.push( + `${kind}:${way} ${outcome}${status} destroys=${of.destroys.join(",")} destroyed=${of.destroyed()} threw=${of.threw.join(",")}`, + ); +} +server.close(); +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/created-error-retention.ts b/packages/binden/test/fixtures/created-error-retention.ts new file mode 100644 index 00000000..81df272f --- /dev/null +++ b/packages/binden/test/fixtures/created-error-retention.ts @@ -0,0 +1,250 @@ +// Run by `created-errors.test.ts` (and the tests of the refusals that it +// keeps) with `--expose-gc`: an error that binden creates and throws, rejects +// with or hands over must not keep the request nor the response once the +// application keeps it without reading its stack. Modes: `argument` (the +// `TypeError` of `response.status(99)`, caught by the middleware), `dotfile` +// (the `403` that `sendFile()` rejects with for a dotfile it denies), `hidden` +// (its `404` for a dotfile it ignores), `outside` (its `404` for a path that +// leaves its root), `url` (the `400` that `context.url` throws for an invalid +// `Host`), `redirect` (the `TypeError` of `response.redirect()` for a location +// that is not a URL), `json` (the `TypeError` that `JSON.stringify()` throws in +// `response.json()` for a nested `bigint`), `detached` (the `TypeError` of +// `response.send()` for a view of a detached `ArrayBuffer`), `form` (the +// `TypeError` of `response.form(null)`), `options` (of `response.redirect()` +// with `null` options), `stringify` (of `response.json()` with a `stringify` +// that is not a function), and the refusals of values that binden describes +// without converting them, or whose reading throws (`statusSymbol`, +// `statusNullProto`, `redirectStatusNullProto`, `typeSymbol`, `varySymbol`, +// `varyNull`, `setNull`, `sendEncodingNullProto`, `writeEncodingNullProto`, +// `clearSameSiteSymbol`, `clearSameSiteNull`, `clearExpiresProxy`, +// `jsonClass` and `jsonRevoked`: see `refusals`), the refusals of the entry +// points that format what they throw (`cookieNull`, `acceptsSymbol`, +// `headerNull`, `errorRevoked`, `sendFileRevoked`, `setHeaderSymbol`, through +// an override of Node.js, and `compressionEncoding`, of a write through +// @binden/compression, whose built entry it uses, with the one of binden), +// caught by the middleware, and `throw` (the `BindenError` of +// `context.throw()`): the error handler keeps the error. +// Prints whether the responses and the requests are collected, and the kinds +// of the errors that the application keeps. +import { type AddressInfo, connect } from "node:net"; +import type { + Context, + BindenResponse as IResponse, + Middleware, +} from "../../index.js"; +import { HOST, capture } from "../helpers.js"; +import { once } from "node:events"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [mode = "argument"] = process.argv.slice(2); + +/** Whether the mode uses the built entries (of binden and of a package) */ +const built = mode === "compressionEncoding"; +/** Imports a module by a specifier that is not resolved at compile time */ +async function load(specifier: string): Promise { + return (await import(specifier)) as unknown; +} +const { Binden, BindenError, BindenRequest, BindenResponse, Cookie } = built + ? ((await load("binden")) as typeof import("../../index.js")) + : await import("../../index.js"); +/** The middleware of @binden/compression (of `compressionEncoding`) */ +const Compression = built + ? ( + (await load("@binden/compression")) as { + readonly Compression: new (options: object) => object; + } + ).Compression + : null; + +/** A view of a detached `ArrayBuffer` (transferred) */ +function detached(): Uint8Array { + const bytes = new Uint8Array(4); + bytes.buffer.transfer(); + return bytes; +} +/** A value of any type, given in place of the one that a method declares */ +function given(value: unknown): T { + return value as T; +} + +/** A class, whose constructor cannot be called as a function */ +class Stringify { + public readonly created = Date.now(); +} + +/** A function whose proxy has been revoked: calling it throws */ +function revoked(): () => string { + const { proxy, revoke } = Proxy.revocable(() => "{}", {}); + revoke(); + return proxy; +} + +/** An object whose proxy has been revoked: reading it throws */ +function revokedObject(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** + * The calls of the modes whose refusal the middleware catches (thrown or + * rejected with): of a location that is not a URL, a nested `bigint`, a view + * of a detached `ArrayBuffer`, and the arguments that JavaScript may give in + * place of the declared ones (e.g. a `Symbol`, or an object without a + * primitive value, which a conversion would refuse with an error of V8) + */ +const refusals: Partial< + Record unknown> +> = { + redirect: (response) => response.redirect("http://[bad"), + json: (response) => response.json({ big: 1n }), + detached: (response) => response.send(detached()), + form: (response) => response.form(null as unknown as URLSearchParams), + options: (response) => + response.redirect("/", 302, null as unknown as { allow_external: false }), + stringify: (response) => + response.json({}, 5 as unknown as (data: object) => string), + statusSymbol: (response) => response.status(given(Symbol("status"))), + statusNullProto: (response) => response.status(given(Object.create(null))), + redirectStatusNullProto: (response) => + response.redirect("/", given(Object.create(null))), + typeSymbol: (response) => response.type(given(Symbol("type"))), + varySymbol: (response) => response.vary(given(Symbol("field"))), + varyNull: (response) => response.vary(given(null)), + setNull: (response) => response.set(given(null)), + sendEncodingNullProto: (response) => + response.send("data", given(Object.create(null))), + writeEncodingNullProto: (response) => + response.write("data", given(Object.create(null))), + clearSameSiteSymbol: (response) => + response.clearCookie("a", { same_site: given(Symbol("same_site")) }), + clearSameSiteNull: (response) => + response.clearCookie("a", { same_site: given(Object.create(null)) }), + clearExpiresProxy: (response) => + response.clearCookie("a", given({ expires: new Proxy(new Date(0), {}) })), + jsonClass: (response) => response.json({}, given(Stringify)), + jsonRevoked: (response) => response.json({}, revoked()), + cookieNull: () => new Cookie(given(null)), + acceptsSymbol: (_, context) => context.request.accepts(given(Symbol("type"))), + headerNull: (_, context) => context.request.header(given(null)), + errorRevoked: () => new BindenError(500, given(revokedObject())), + sendFileRevoked: (response) => response.sendFile(given(revokedObject())), + setHeaderSymbol: (response) => + response.setHeader(given(Symbol("name")), "value"), + compressionEncoding: (response) => + response.write("data", given(Object.create(null))), +}; + +const requests = 60; +const errors: unknown[] = []; + +// Silent: a logger that formatted the errors would format their stacks +const { logger } = capture("silent"); +const app = new Binden({ + logger, + error_handler: (context: Context, error: unknown): Promise => { + errors.push(error); + const { status } = error as { status?: unknown }; + return context.response + .status(typeof status === "number" ? status : 500) + .send(); + }, +}); +if (Compression !== null) { + app.use(new Compression({ threshold: 0 }) as Middleware); +} +app.use((context: Context) => { + const root = import.meta.dirname; + switch (mode) { + case "argument": + try { + context.response.status(99); + } catch (error: unknown) { + errors.push(error); + } + return context.response.status(400).send(); + case "dotfile": + return context.response.sendFile(".env", { root, dotfiles: "deny" }); + case "hidden": + return context.response.sendFile(".env", { root, dotfiles: "ignore" }); + case "outside": + return context.response.sendFile("../../package.json", { root }); + case "url": + return context.response.send(context.url.pathname); + case "throw": + return context.throw(418); + default: + return new Promise((resolve) => { + // What the call throws rejects, as what it rejects with + resolve( + ( + refusals[mode] as (response: IResponse, context: Context) => unknown + )(context.response, context), + ); + }).then( + () => context.response.status(500).send(), + (error: unknown) => { + errors.push(error); + return context.response.status(400).send(); + }, + ); + } +}); + +const server = app.createServer(); +server.listen(0, HOST); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +/** One request on a connection of its own */ +const request = (): Promise => + new Promise((resolve) => { + const socket = connect(port, HOST); + socket.on("error", () => { + // E.g. a reset once the reply is complete + }); + socket.resume(); + socket.once("close", () => { + resolve(); + }); + // An invalid `Host` (with a space) for `url` + const host = mode === "url" ? "bad host" : HOST; + // A body that @binden/compression codes (of `compressionEncoding`) + const coding = built ? "Accept-Encoding: gzip\r\n" : ""; + socket.write( + `GET / HTTP/1.1\r\nHost: ${host}\r\n${coding}Connection: close\r\n\r\n`, + ); + }); +for (let sent = 0; sent < requests; sent += 10) { + await Promise.all(Array.from({ length: 10 }, request)); +} +server.closeAllConnections(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const responses = queryObjects(BindenResponse, { format: "count" }); +const left = Math.max( + responses, + queryObjects(BindenRequest, { format: "count" }), +); +const kinds = new Set( + errors.map((error) => { + const { name, status } = error as { name?: unknown; status?: unknown }; + return typeof status === "number" + ? `${String(name)} ${status}` + : String(name); + }), +); +process.stdout.write( + `${left < requests / 10 ? "collected" : `kept ${left}`} ${errors.length} ${[...kinds].sort().join(", ")}\n`, +); 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/fixtures/derived-loggers.ts b/packages/binden/test/fixtures/derived-loggers.ts new file mode 100644 index 00000000..d53916f3 --- /dev/null +++ b/packages/binden/test/fixtures/derived-loggers.ts @@ -0,0 +1,150 @@ +// Run by `process.test.ts`, without process handlers: a logger derived from +// one of binden (`Object.create(context.log)`, which overrides one method, or +// a proxy of one) logs the failure of a request through the logger of binden, +// and its overridden method is used (by the refusal of `callback()`); one +// whose `getPrototypeOf` trap answers the prototype of binden's loggers while +// its methods throw is ignored; and a context of a middleware whose `log` +// throws once its deadline has passed still gets the reply of the deadline: +// the records of the framework through it are dropped. The process never +// crashes. +import { type AddressInfo, Socket } from "node:net"; +import { + Binden, + BindenRequest, + BindenResponse, + Context, + type IMiddleware, +} from "../../index.js"; +import { + IncomingMessage, + ServerResponse, + createServer, + request, +} from "node:http"; +import type { ILogger } from "@binden/logger"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const messages: string[] = []; +function recorder(): ILogger { + const record = (message: string): void => { + messages.push(message); + }; + return { + fatal: record, + error: record, + warn: record, + info: record, + debug: record, + trace: record, + child: recorder, + }; +} + +/** A logger of binden (`context.log`), whose logger is a recorder */ +function bindenLogger(): ILogger { + const socket = new Socket(); + const message = new BindenRequest(socket); + return new Context({ + request: message, + response: new BindenResponse(message), + logger: recorder(), + }).log; +} + +const overridden: string[] = []; +const derived = Object.assign(Object.create(bindenLogger()) as ILogger, { + error(message: string): void { + overridden.push(message); + }, +}); +const thrower = (): never => { + throw new RangeError("A failing logger"); +}; +const prototype = Object.getPrototypeOf(bindenLogger()) as object; +const lying = new Proxy( + { + fatal: thrower, + error: thrower, + warn: thrower, + info: thrower, + debug: thrower, + trace: thrower, + child: thrower, + }, + { getPrototypeOf: (): object => prototype }, +); +const loggers: Record = { + derived, + proxy: new Proxy(bindenLogger(), {}), + lying, +}; + +/** + * A context whose `log` throws once its deadline has passed: what the + * framework logs through it is dropped, and the deadline replies (`503`) + */ +class Unlogged extends Context { + public override get log(): ILogger { + if (this.signal.aborted) { + throw new RangeError("No logger"); + } + return super.log; + } +} +const unlogged: IMiddleware = ({ request: message, response }) => + new Unlogged({ request: message, response }); + +/** Hangs for a context whose `log` throws (until its deadline has passed) */ +async function error_handler(context: Context): Promise { + if (context instanceof Unlogged) { + await new Promise((resolve) => { + setTimeout(resolve, 1000).unref(); + }); + } +} + +async function status(port: number, path: string): Promise { + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }).catch(() => null); + if (response === null) { + return "closed"; + } + response.resume(); + await once(response, "end"); + return String(response.statusCode); +} + +const results: string[] = []; +for (const [name, logger] of Object.entries(loggers)) { + const app = new Binden({ logger, timeout: 50, error_handler }); + // Its context is the one of the error handler + app.use("/unlogged", unlogged); + app.use(() => { + throw new Error("A failure"); + }); + const server = app.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + messages.length = 0; + const statuses = [ + await status(port, "/fails"), + await status(port, "/unlogged"), + ]; + const logged = messages.includes("The request has failed"); + // Refused, and logged by the logger of the application itself + const foreign = new IncomingMessage(new Socket()); + app.callback(createServer())(foreign, new ServerResponse(foreign)); + results.push( + `${name} ${statuses.join(" ")} ${logged} ${overridden.splice(0).length}`, + ); + server.close(); +} +process.stdout.write(`${results.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/detached-chunks.ts b/packages/binden/test/fixtures/detached-chunks.ts new file mode 100644 index 00000000..988d36ea --- /dev/null +++ b/packages/binden/test/fixtures/detached-chunks.ts @@ -0,0 +1,77 @@ +// Run by `detached-views.test.ts`, without a handler of the process: an +// object-mode `Readable` whose chunk is a view of a detached `ArrayBuffer` +// (`first`: its first chunk, a `Uint8Array`; `view`: its first chunk, a +// `DataView`; `later`: a `Uint8Array` after a valid chunk) must not crash the +// process (Node.js throws once it writes it, out of any call). The send +// rejects with a `TypeError` of binden: before the headers are committed for a +// first chunk, which the middleware replies to (`500`), and the pipelined +// request is answered; otherwise the response is cut. Prints the outcome. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { HOST } from "../helpers.js"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [mode = "first"] = process.argv.slice(2); + +/** A view of a detached `ArrayBuffer` (transferred) */ +function detached(): ArrayBufferView { + const bytes = new Uint8Array(4); + const view = mode === "view" ? new DataView(bytes.buffer) : bytes; + bytes.buffer.transfer(); + return view; +} + +let outcome: Record = { send: "pending" }; +const app = new Binden().use(async (context: Context) => { + if (context.url.pathname === "/second") { + await context.send("second"); + return; + } + const source = Readable.from( + mode === "later" ? [Buffer.from("ok"), detached()] : [detached()], + { objectMode: true }, + ); + try { + await context.send(source); + outcome = { send: "resolved" }; + } catch (error: unknown) { + const { headersSent } = context.response; + outcome = { + send: `${(error as Error).constructor.name}: ${(error as Error).message}`, + headersSent, + destroyed: source.destroyed, + }; + if (!headersSent) { + await context.response.status(500).send("replied"); + } + } +}); +const server = app.createServer().listen(0, HOST); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const socket = connect(port, HOST); +let received = ""; +socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; +}); +socket.on("error", () => { + // E.g. a reset of the cut response +}); +await once(socket, "connect"); +socket.write( + `GET / HTTP/1.1\r\nHost: ${HOST}\r\n\r\nGET /second HTTP/1.1\r\nHost: ${HOST}\r\nConnection: close\r\n\r\n`, +); +await once(socket, "close"); +server.close(); +process.stdout.write( + `${JSON.stringify({ + ...outcome, + replies: received.match(/HTTP\/1\.1 \d{3}|second/gu), + })}\n`, +); diff --git a/packages/binden/test/fixtures/detaching-writes.ts b/packages/binden/test/fixtures/detaching-writes.ts new file mode 100644 index 00000000..560bf4f3 --- /dev/null +++ b/packages/binden/test/fixtures/detaching-writes.ts @@ -0,0 +1,154 @@ +// Run by `detached-views.test.ts`, without a handler of the process: the +// `toString()` of a cookie of the application, which runs inside the write of +// binden that commits the head (after binden has counted the bytes of a view), +// detaches the `ArrayBuffer` that binden's `Buffer` shares (`send`: the view +// given to `send()`; `cl`: the first chunk of a `Readable` of a +// `Content-Length` of 20 bytes; `chunked`: the same without a length), or +// (`shrink`) shrinks the resizable `ArrayBuffer` of a view given to `send()`, +// whose bytes are then out of the bounds of binden's `Buffer`, or (`ending`) +// a view given to `send()` whose `write()` (of a writer of the application) +// gives the bytes to binden, then ends the response inside that write, or +// (`end`, and `end-chunks` for a `Readable` of views) gives them to binden +// by `end()` instead, or (`unreported`, and `unreported-chunks`) replaces the +// response with an empty `406` (whose head detaches the bytes), drops them, +// and calls back the write, without telling binden. The send must reject +// with the `TypeError` of binden, formatted before the application gets it, +// and the connection must be closed before its head is flushed: the +// pipelined request is never answered (its response would be read as the +// body), and the process does not crash. But (`replaced`, a view given to +// `send()`, and `replaced-chunks`, a `Readable` of views) a writer that +// replaces the response in the same way, and reports each write that it +// discards (`kDiscardWrite`), drops them: they are no part of the response, +// the send resolves, and the pipelined request is answered. Prints the +// outcome. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context, kDiscardWrite } from "../../index.js"; +import { HOST } from "../helpers.js"; +import type { ILogger } from "@binden/logger"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [mode = "send"] = process.argv.slice(2); + +/** The errors whose stack V8 has formatted */ +const formatted = new WeakSet(); +Error.prepareStackTrace = (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; +}; + +const silent: ILogger = { + fatal: () => null, + error: () => null, + warn: () => null, + info: () => null, + debug: () => null, + trace: () => null, + child: () => silent, +}; + +let outcome: Record = { send: "pending" }; +const app = new Binden({ logger: silent }).use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.send("next"); + return; + } + const buffer = new ArrayBuffer(16, { maxByteLength: 32 }); + new Uint8Array(buffer).fill(0x61); + context.response.cookies.add({ + toString(): string { + if (mode === "shrink") { + buffer.resize(8); + } else if (!buffer.detached) { + buffer.transfer(); + } + return "a=b"; + }, + } as never); + if (mode === "cl") { + context.response.setHeader("Content-Length", 20); + } else if (mode === "ending") { + const { response } = context; + const write = response.write.bind(response) as ( + ...args: unknown[] + ) => boolean; + response.write = (...args: unknown[]): boolean => { + const accepted = write(...args); + response.end(); + return accepted; + }; + } else if (mode.startsWith("end")) { + const { response } = context; + response.write = (chunk: unknown, ...args: unknown[]): boolean => { + response.end(chunk); + const callback = args.find((arg) => typeof arg === "function"); + if (typeof callback === "function") { + process.nextTick(callback, null); + } + return true; + }; + } else if (mode.startsWith("replaced") || mode.startsWith("unreported")) { + const { response } = context; + response.write = (...args: unknown[]): boolean => { + if (!response.writableEnded) { + response.statusCode = 406; + response.setHeader("Content-Length", 0).end(); + } + if (mode.startsWith("replaced")) { + response[kDiscardWrite](); + } + const callback = args.find((arg) => typeof arg === "function"); + if (typeof callback === "function") { + process.nextTick(callback, null); + } + return true; + }; + } + const data = [ + "send", + "shrink", + "ending", + "end", + "replaced", + "unreported", + ].includes(mode) + ? new Uint8Array(buffer) + : Readable.from([new Uint8Array(buffer), Buffer.from("tail")], { + objectMode: true, + }); + try { + await context.response.send(data); + outcome = { send: "resolved" }; + } catch (error: unknown) { + outcome = { + // Before anything reads its stack + formatted: formatted.has(error as Error), + send: `${(error as Error).constructor.name}: ${(error as Error).message}`, + headersSent: context.response.headersSent, + destroyed: context.response.destroyed, + }; + } +}); +const server = app.createServer().listen(0, HOST); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const socket = connect(port, HOST); +let received = ""; +socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; +}); +socket.on("error", () => { + // E.g. a reset of the cut response +}); +await once(socket, "connect"); +socket.write( + `GET / HTTP/1.1\r\nHost: ${HOST}\r\n\r\nGET /next HTTP/1.1\r\nHost: ${HOST}\r\nConnection: close\r\n\r\n`, +); +await once(socket, "close"); +server.close(); +process.stdout.write(`${JSON.stringify({ ...outcome, received })}\n`); diff --git a/packages/binden/test/fixtures/discarded-source.ts b/packages/binden/test/fixtures/discarded-source.ts new file mode 100644 index 00000000..cd65b08c --- /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 { Binden } from "../../index.js"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +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, "127.0.0.1"); +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, host: "127.0.0.1", 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/fixtures/echo-errors.ts b/packages/binden/test/fixtures/echo-errors.ts new file mode 100644 index 00000000..b131cf18 --- /dev/null +++ b/packages/binden/test/fixtures/echo-errors.ts @@ -0,0 +1,142 @@ +// Run by `release.test.ts`: the request of a response, sent as its body (an +// echo), without a listener of `error` of the application. Node.js schedules +// the error of a request (e.g. of a client that aborts) once it has found a +// listener: the guard of the request listens before the send removes its own +// one, so that an error scheduled then does not crash the process, and the +// guard logs it at `debug` (the send never destroys the request). Modes: +// `write` (a write of the response fails while the request aborts: Node.js +// emits its error on the next turn) and `rst` or `fin` (the client of an echo +// that the application has paused resets or closes its connection once the +// send has failed, its body being longer than the `Content-Length` of the +// response). Prints how the send has settled and the listeners of `error` +// left on the request once it has closed, then the level and the code of +// what the guard has logged. +import { + Binden, + BindenResponse, + type Context, + releasedFailure, +} from "../../index.js"; +import { type Socket, connect } from "node:net"; +import { Duplex } from "node:stream"; +import { IncomingMessage } from "node:http"; +import { capture } from "../helpers.js"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +/** + * Settles once the request has closed, on the turn after its close: when the + * guard of the request, whose listener of `close` comes before the one added + * here, has removed itself on that turn (an immediate that runs before the + * one queued here, unlike a timer, which a loaded machine may run first). No + * listener of `error` is added (unlike `once()`). + */ +async function closed(request: IncomingMessage): Promise { + if (!request.closed) { + await new Promise((resolve) => { + request.once("close", resolve); + }); + } + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +/** How a send has settled: `resolved`, or the code (or the message) of its error */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => { + const { code, message } = error as { code?: unknown; message: string }; + return typeof code === "string" ? code : message; + }, + ); +} + +const [mode = "write"] = process.argv.slice(2); + +if (mode === "write") { + // A connection whose writes complete once they are told so + let complete: ((error?: Error | null) => void) | null = null; + const wire = new Duplex({ + read(): void { + // Nothing comes + }, + write(_chunk, _encoding, callback): void { + complete = callback; + }, + }).on("error", () => { + // Destroyed below + }); + const request = new IncomingMessage(wire as unknown as Socket); + Object.assign(request, { + method: "POST", + complete: false, + headers: { "content-length": "10" }, + httpVersionMajor: 1, + httpVersionMinor: 1, + }); + const response = new BindenResponse(request); + response.assignSocket(wire as unknown as Socket); + const sent = outcomeOf(response.send(request)); + request.push(Buffer.from("abc")); + // Once its first chunk is written + await new Promise((resolve) => { + setImmediate(resolve); + }); + wire.destroy(); + request.destroy(new Error("Request aborted")); + process.nextTick(() => { + complete?.(new Error("Write aborted")); + }); + const outcome = await sent; + // The guard is removed on the turn after the close + await closed(request); + process.stdout.write(`${outcome} ${request.listenerCount("error")}\n`); +} else { + const captured = capture(); + const settled = Promise.withResolvers<[string, IncomingMessage]>(); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + const { request, response } = context; + // Its buffered body is longer than the length of the response + request.pause(); + response.setHeader("Content-Length", "10"); + settled.resolve([await outcomeOf(context.send(request)), request]); + // The response stays open until its client goes away (its request is + // aborted then): never by a listener of `error` (e.g. of `once()`) + await new Promise((resolve) => { + request.on("close", resolve); + }); + }, + ); + const server = app.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as { port: number }; + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => { + // Reset below + }); + await once(socket, "connect"); + socket.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000000\r\n\r\n", + ); + socket.write(Buffer.alloc(4000, 0x61)); + const [outcome, request] = await settled.promise; + if (mode === "rst") { + socket.resetAndDestroy(); + } else { + socket.destroy(); + } + const record = await captured.waitFor(releasedFailure); + // The guard is removed on the turn after the close + await closed(request); + server.close(); + process.stdout.write( + `${outcome} ${request.listenerCount("error")}\n${String(record.level)} ${String((record.failure as { code?: unknown }).code)}\n`, + ); +} diff --git a/packages/binden/test/fixtures/error-retention.ts b/packages/binden/test/fixtures/error-retention.ts new file mode 100644 index 00000000..a6ececbf --- /dev/null +++ b/packages/binden/test/fixtures/error-retention.ts @@ -0,0 +1,143 @@ +// Run by `error-retention.test.ts` with `--expose-gc`: an error that binden +// creates and hands over (the reason of an aborted signal, the error that a +// send rejects with or destroys its source with, or the one that fails a +// work) must not keep the response once the application, or a source, +// keeps it. Kinds: `native` (a `PassThrough`, which keeps the error it is +// destroyed with as its `errored`) and `like` (a stream-like source, which +// keeps it as minipass does). Modes: `deadline` (cancelled at the deadline), +// `gone` (the client goes away), `mismatch` (a body shorter than its +// `Content-Length`, which ends: nothing destroys it with the error, whose +// stack Node.js would format) and `refused` (a pipe of a `Proxy`, which fails +// its work). Prints whether the responses are collected, and the names (and +// codes) of the errors that the sources and the application keep. +import { type AddressInfo, connect } from "node:net"; +import { Binden, BindenResponse, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { HOST, capture } from "../helpers.js"; +import { PassThrough } from "node:stream"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [kind = "like", mode = "deadline"] = process.argv.slice(2); +const requests = 60; + +/** A stream-like source that keeps the error it is destroyed with */ +class Keeping extends EventEmitter { + public destroyed = false; + public kept: unknown = null; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: Error): this { + this.destroyed = true; + this.kept = error ?? null; + return this; + } +} + +/** What the sources and the application keep */ +const sources: (Keeping | PassThrough)[] = []; +const errors: unknown[] = []; +/** The error that a source keeps (see `Keeping`) */ +function keptBy(source: Keeping | PassThrough): unknown { + return source instanceof Keeping ? source.kept : source.errored; +} + +// Silent: a logger that formatted the errors would format their stacks +const { logger } = capture("silent"); +const app = new Binden({ + logger, + timeout: mode === "deadline" ? 30 : 0, + error_handler: (context: Context, error: unknown): Promise => { + errors.push(error); + return context.response.status(500).send(); + }, +}).use(async (context: Context) => { + const source = kind === "native" ? new PassThrough() : new Keeping(); + sources.push(source); + const write = (chunk: string, end = false): void => { + if (source instanceof Keeping) { + source.emit("data", Buffer.from(chunk)); + if (end) { + source.emit("end"); + } + } else if (end) { + source.end(chunk); + } else { + source.write(chunk); + } + }; + if (mode === "refused") { + // Its work fails with the refusal (see the error handler) + new Proxy(new PassThrough(), {}).pipe(context.response); + return; + } else if (mode === "mismatch") { + context.response.setHeader("Content-Length", 3); + setImmediate(write, "x", true); + } else { + // Its first chunk commits the headers + setImmediate(write, "x"); + } + await context.send(source).catch((error: unknown) => { + errors.push(error); + }); +}); + +const server = app.createServer(); +server.listen(0, HOST); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +/** One request on a connection of its own, which `gone` closes at its first byte */ +const request = (): Promise => + new Promise((resolve) => { + const socket = connect(port, HOST); + socket.on("error", () => { + // E.g. a reset once the response is cut + }); + socket.on("data", () => { + if (mode === "gone") { + socket.destroy(); + } + }); + socket.once("close", () => { + resolve(); + }); + socket.write( + `GET / HTTP/1.1\r\nHost: ${HOST}\r\nConnection: close\r\n\r\n`, + ); + }); +for (let sent = 0; sent < requests; sent += 10) { + await Promise.all(Array.from({ length: 10 }, request)); +} +server.closeAllConnections(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const left = queryObjects(BindenResponse, { format: "count" }); +const names = new Set( + [...sources.map(keptBy), ...errors] + .filter((error) => error instanceof Error) + .map((error) => { + const code: unknown = Reflect.get(error, "code"); + return typeof code === "string" ? `${error.name} ${code}` : error.name; + }), +); +process.stdout.write( + `${left < requests / 10 ? "collected" : `kept ${left}`} ${[...names].sort().join(", ")}\n`, +); diff --git a/packages/binden/test/fixtures/estimated-lengths.ts b/packages/binden/test/fixtures/estimated-lengths.ts new file mode 100644 index 00000000..00908d7b --- /dev/null +++ b/packages/binden/test/fixtures/estimated-lengths.ts @@ -0,0 +1,227 @@ +// Run by `process.test.ts`: a string in an encoding whose length Node.js only +// estimates (`hex`, `base64` and `base64url`, in any case) is written as the +// bytes that `Buffer.from()` decodes, and framed by them: an odd number of hex +// digits (Node.js 24 aborts the process when it writes one to a socket, e.g. +// as it flushes a corked socket or a response queued behind another), an +// invalid string (Node.js would declare more bytes than it writes), and a +// string that decodes to nothing. Each response is parsed strictly, and the +// next response of the connection must be read as such. Prints the number of +// exchanges that are as expected, and every other one. +import { type AddressInfo, connect } from "node:net"; +import { type IncomingMessage, createServer } from "node:http"; +import { BindenResponse } from "../../index.js"; +import { once } from "node:events"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, (error: unknown) => { + process.stderr.write(`${event} ${String(error)}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +/** A reply, and the body that the client must read */ +type ICase = [reply: (response: BindenResponse) => unknown, body: string]; + +const cases: Record = { + "end(odd hex)": [(r): unknown => r.end("68692", "hex"), "hi"], + "write(odd hex) + end()": [ + (r): void => { + r.write("68692", "hex"); + r.end(); + }, + "hi", + ], + // Empty: `Content-Length: 0` + "end(invalid hex)": [(r): unknown => r.end("zz", "hex"), ""], + // An empty chunk is not framed as a chunk of one byte + "write(invalid hex) + end(hex)": [ + (r): void => { + r.write("zz", "hex"); + r.end("6869", "hex"); + }, + "hi", + ], + "end(invalid base64)": [(r): unknown => r.end("aGk!!!", "base64"), "hi"], + "end(base64url)": [(r): unknown => r.end("aGk", "base64url"), "hi"], + "end(invalid base64url)": [ + (r): unknown => r.end("aGk!!!", "base64url"), + "hi", + ], + "upper-case encodings": [ + (r): void => { + r.write("zz", "HEX" as BufferEncoding); + r.write("aGk!!!", "BASE64" as BufferEncoding); + r.end("aGk!!!", "Base64Url" as BufferEncoding); + }, + "hihi", + ], + // Never replaced: checked against the bytes written + "a strict Content-Length, several writes": [ + (r): void => { + r.strictContentLength = true; + r.setHeader("Content-Length", 4); + r.write("6869", "hex"); + r.write("zz", "hex"); + r.end("68692", "hex"); + }, + "hihi", + ], + // Written together (`writev()` of the socket) + "corked writes": [ + (r): void => { + r.cork(); + r.write("68692", "hex"); + r.write("aGk!!!", "base64"); + r.write("616", "hex"); + r.uncork(); + r.end(); + }, + "hihia", + ], +}; + +/** + * A response at the start of `data`, framed strictly (by its + * `Content-Length`, or by chunks), and the data after it; `null` when it is + * not one + */ +function parse(data: Buffer): { body: string; rest: Buffer } | null { + const end = data.indexOf("\r\n\r\n"); + if (end === -1) { + return null; + } + const head = data.subarray(0, end).toString("latin1").split("\r\n"); + const fields = new Map( + head.slice(1).map((line) => { + const colon = line.indexOf(":"); + return [line.slice(0, colon).toLowerCase(), line.slice(colon + 1).trim()]; + }), + ); + let rest = data.subarray(end + 4); + const length = fields.get("content-length"); + if (typeof length !== "undefined") { + const size = Number(length); + return rest.length < size + ? null + : { + body: rest.subarray(0, size).toString("latin1"), + rest: rest.subarray(size), + }; + } else if (fields.get("transfer-encoding") !== "chunked") { + return null; + } + let body = ""; + for (;;) { + const line = rest.indexOf("\r\n"); + const size = Number.parseInt(rest.subarray(0, line).toString(), 16); + const chunk = rest.subarray(line + 2, line + 2 + size); + if ( + line === -1 || + !Number.isSafeInteger(size) || + chunk.length !== size || + rest.subarray(line + 2 + size, line + 4 + size).toString() !== "\r\n" + ) { + return null; + } + body += chunk.toString("latin1"); + rest = rest.subarray(line + 4 + size); + if (size === 0) { + return { body, rest }; + } + } +} + +const server = createServer( + { ServerResponse: BindenResponse }, + (request: IncomingMessage, response: BindenResponse) => { + const [, name = "", mode = ""] = (request.url ?? "/").split("/"); + if (name === "first") { + // The response after it is queued until then + setTimeout(() => { + response.end("first"); + }, 20); + return; + } else if (name === "next") { + response.end("next"); + return; + } else if (mode === "hints") { + response.writeEarlyHints({ link: "; rel=preload" }); + } + const [reply] = cases[decodeURIComponent(name)]; + try { + reply(response); + } catch (error: unknown) { + // A failure of the case (e.g. a length that Node.js finds wrong) + process.stdout.write(`${name}: ${String(error)}\n`); + response.destroy(); + } + }, +); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +/** + * The responses of one connection, until the server closes it (or half a second + * has passed, e.g. once a response has declared more bytes than it has sent) + */ +async function exchange(requests: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.write(requests); + await Promise.race([ + once(socket, "close"), + new Promise((resolve) => { + setTimeout(resolve, 500); + }), + ]); + socket.destroy(); + return Buffer.concat(chunks); +} + +const get = (path: string, last = false): string => + `GET ${path} HTTP/1.1\r\nHost: a\r\n${last ? "Connection: close\r\n" : ""}\r\n`; +const failures: string[] = []; +let passed = 0; +// Alone, queued behind another response, and after early hints, queued +for (const mode of ["alone", "queued", "hints"]) { + for (const [name, [, expected]] of Object.entries(cases)) { + const path = `/${encodeURIComponent(name)}/${mode}`; + const data = await exchange( + (mode === "alone" ? "" : get("/first")) + get(path) + get("/next", true), + ); + // The response before it, and its early hints + const first = mode === "alone" ? null : parse(data); + let rest = first?.rest ?? data; + const hints = "HTTP/1.1 103 Early Hints\r\n"; + if ( + mode === "hints" && + rest.subarray(0, hints.length).toString() === hints + ) { + rest = rest.subarray(rest.indexOf("\r\n\r\n") + 4); + } + const reply = parse(rest); + const next = reply === null ? null : parse(reply.rest); + if ( + (mode === "alone" || first?.body === "first") && + rest.subarray(0, 9).toString() === "HTTP/1.1 " && + reply?.body === expected && + next?.body === "next" && + next.rest.length === 0 + ) { + passed += 1; + } else { + failures.push( + `${name} (${mode}): ${JSON.stringify(data.toString("latin1"))}`, + ); + } + } +} +process.stdout.write(`${[passed, ...failures].join("\n")}\n`); +server.close(); diff --git a/packages/binden/test/fixtures/fallback-retention.ts b/packages/binden/test/fixtures/fallback-retention.ts new file mode 100644 index 00000000..0525c1fb --- /dev/null +++ b/packages/binden/test/fixtures/fallback-retention.ts @@ -0,0 +1,155 @@ +// Run by `guard-refusals.test.ts` with `--expose-gc`: sources whose guard +// cannot be installed (they refuse its listener of `error`, the second one), +// a Node.js `Readable` and a stream-like source, which keep the listener of +// `error` of their send, and a Node.js `Readable` whose guard stays (it never +// closes), each sent whole by a third of the requests. Neither listener may +// keep the response once the send has settled: each holds nothing but the +// report of a late error. Prints whether the responses are collected, the +// listeners of `error` left on the sources of each kind (one each), the late +// errors that they log once the sources fail, and the refusals logged. +import { Agent, get } from "node:http"; +import { Binden, BindenResponse, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { capture, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +/** + * Makes a source refuse its second listener of `error`: a listener of its + * `newListener` throws for it (a `Readable`, whose guard is added by the + * methods of `EventEmitter`), or its own `on()` (a stream-like source) + */ +function refusing(source: T): T { + let listeners = 0; + const refuse = (event: string): void => { + if (event === "error") { + listeners += 1; + if (listeners === 2) { + throw new Error("No guard"); + } + } + }; + if (source instanceof Readable) { + return source.on("newListener", refuse); + } + const on = Reflect.get(source, "on") as (...args: unknown[]) => unknown; + return Object.assign(source, { + on(event: string, listener: () => void): unknown { + refuse(event); + return Reflect.apply(on, source, [event, listener]); + }, + }); +} + +/** A stream-like source, sent whole on the next turn */ +class Whole extends EventEmitter { + public constructor() { + super(); + setImmediate(() => { + this.emit("data", Buffer.from("ok")); + this.emit("end"); + }); + } + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } +} + +/** The kinds of sources, by path: each other request gets one of them */ +const kinds = ["/readable", "/like", "/guarded"]; +const requests = 300; +/** The sources, by kind */ +const sources = new Map( + kinds.map((kind) => [kind, []]), +); +const captured = capture(); +const app = new Binden({ + logger: captured.logger, + log_error_details: true, +}).use(async (context: Context) => { + const { pathname } = context.url; + const readable = (): Readable => + new Readable({ + autoDestroy: false, + read(): void { + this.push("ok"); + this.push(null); + }, + }); + const source = + pathname === "/like" + ? refusing(new Whole()) + : pathname === "/readable" + ? refusing(readable()) + : readable(); + sources.get(pathname)?.push(source); + await context.send(source); +}); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const agent = new Agent({ keepAlive: true, maxSockets: 8 }); +const request = (path: string): Promise => + new Promise((resolve, reject) => { + get({ port, agent, path, host: "127.0.0.1" }, (response) => { + response.resume().once("end", resolve); + }).once("error", reject); + }); +for (let sent = 0; sent < requests; sent += 6) { + await Promise.all( + Array.from({ length: 6 }, (_value, index) => + request(kinds[index % kinds.length]), + ), + ); +} +agent.destroy(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const left = queryObjects(BindenResponse, { format: "count" }); +/** The listeners of `error` left on the sources of each kind */ +const listeners = kinds.map((kind) => + (sources.get(kind) ?? []).reduce( + (count, source) => count + source.listenerCount("error"), + 0, + ), +); +/** The guards that could not be installed */ +const refused = captured.records.filter( + ({ failure }) => + (failure as { message?: unknown } | undefined)?.message === "No guard", +).length; +// Each source fails: its listener of `error` logs it +for (const source of [...sources.values()].flat()) { + source.emit("error", new Error("Late failure")); +} +const late = (): number => + captured.records.filter( + ({ level, failure }) => + level === 20 && + (failure as { message?: unknown } | undefined)?.message === + "Late failure", + ).length; +await pollUntil(() => late() >= requests, 5_000).catch(() => null); +process.stdout.write( + `${left < requests / 10 ? "collected" : `kept ${left}`} ${listeners.join(" ")} ${late()} ${refused}\n`, +); diff --git a/packages/binden/test/fixtures/flat-loggers.ts b/packages/binden/test/fixtures/flat-loggers.ts new file mode 100644 index 00000000..edcf5010 --- /dev/null +++ b/packages/binden/test/fixtures/flat-loggers.ts @@ -0,0 +1,69 @@ +// Run by `retention.test.ts` with `--expose-gc`: a `child()` of the logger +// of the application that gives the last `context.log` (e.g. a cache) must +// not make a `context.log` keep the ones made before it. Prints whether the +// first one is collected, and how many loggers of binden are left, after a +// full collection. +import { Agent, get } from "node:http"; +import { Binden, type Context } from "../../index.js"; +import type { AddressInfo } from "node:net"; +import type { ILogger } from "@binden/logger"; +import { once } from "node:events"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const requests = Number(process.argv[2] ?? "1000"); +let last: ILogger | null = null; +let first = null as WeakRef | null; +const write = (): void => { + // Dropped +}; +const caching: ILogger = { + fatal: write, + error: write, + warn: write, + info: write, + debug: write, + trace: write, + child: (): ILogger => last ?? caching, +}; + +const app = new Binden({ logger: caching }).use(async (context: Context) => { + last = context.log; + first ??= new WeakRef(last); + await context.text("ok"); +}); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const agent = new Agent({ keepAlive: true, maxSockets: 8 }); +const request = (): Promise => + new Promise((resolve, reject) => { + get({ port, agent, host: "127.0.0.1" }, (response) => { + response.resume().once("end", resolve); + }).once("error", reject); + }); +for (let sent = 0; sent < requests; sent += 8) { + await Promise.all(Array.from({ length: 8 }, request)); +} +agent.destroy(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const kept = last as unknown as ILogger; +const left = queryObjects(kept.constructor, { format: "count" }); +const collected = typeof first?.deref() === "undefined"; +process.stdout.write(`${collected ? "collected" : "kept"} ${left}\n`); +// Still in use +kept.info("kept"); diff --git a/packages/binden/test/fixtures/foreign-length.ts b/packages/binden/test/fixtures/foreign-length.ts new file mode 100644 index 00000000..0ccf7630 --- /dev/null +++ b/packages/binden/test/fixtures/foreign-length.ts @@ -0,0 +1,72 @@ +// Run by `process.test.ts`, without a handler of the process: a send of a +// `Readable` whose head the application commits around binden (by +// `writeHead()` of Node.js) once it has set a `Content-Length` of another +// type (an object whose conversion is a length), after the send has started. +// The client goes away once it has read the first chunk of the body: whether +// the body is complete is decided as the response closes, which must neither +// throw (the process would crash) nor take the refused value for the length. +// Prints how the send has settled. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { HOST } from "../helpers.js"; +import type { ILogger } from "@binden/logger"; +import { Readable } from "node:stream"; +import { ServerResponse } from "node:http"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const silent: ILogger = { + fatal: () => null, + error: () => null, + warn: () => null, + info: () => null, + debug: () => null, + trace: () => null, + child: () => silent, +}; + +const { promise: settled, resolve } = Promise.withResolvers(); +const app = new Binden({ logger: silent }).use(async (context: Context) => { + const { response } = context; + response.setHeader("Content-Length", 6); + const source = new Readable({ + read(): void { + // Pushed below + }, + }); + const sent = response.send(source); + response.setHeader("Content-Length", { + toString: (): string => "6", + } as never); + ServerResponse.prototype.writeHead.call(response, 200); + source.push("abc"); + resolve( + await sent.then( + () => "resolved", + () => "rejected", + ), + ); +}); +const server = app.createServer().listen(0, HOST); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const socket = connect(port, HOST); +let received = ""; +socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + if (received.endsWith("abc")) { + socket.destroy(); + } +}); +socket.on("error", () => { + // Gone +}); +await once(socket, "connect"); +socket.write(`GET / HTTP/1.1\r\nHost: ${HOST}\r\n\r\n`); +const outcome = await settled; +server.close(); +process.stdout.write(`${outcome}\n`); diff --git a/packages/binden/test/fixtures/foreign-pipes.ts b/packages/binden/test/fixtures/foreign-pipes.ts new file mode 100644 index 00000000..6e56f9b2 --- /dev/null +++ b/packages/binden/test/fixtures/foreign-pipes.ts @@ -0,0 +1,116 @@ +// Run by `process.test.ts`: request A pipes its source into its own response, +// whose head Node.js refuses (`pipeline()`, an empty source), and also a plain +// pipe into the response of a concurrent request B. The end of its +// `pipeline()` that throws fails A, by the handler of its own exchange, which +// the pipe into B does not replace: no crash, and each request gets its own +// reply (`500` for A, `200` for B), with the promise and the callback forms +// of `pipeline()`, and without the pipe into B. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type BindenResponse, type Context } from "../../index.js"; +import { PassThrough, pipeline } from "node:stream"; +import { once } from "node:events"; +import { pipeline as pipelineAsync } from "node:stream/promises"; +import { pollUntil } from "../helpers.js"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, (error: unknown) => { + process.stderr.write(`${event} ${String(error)}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const never = (): Promise => + new Promise(() => { + // Never settles + }); +/** The codes of the errors of the pipelines of A, by case */ +const rejected: string[] = []; +/** The request B that waits (its response, and what lets it reply) */ +interface IWaiting { + readonly response: BindenResponse; + reply(): void; +} +const waiting: IWaiting[] = []; + +const app = new Binden().use(async (context: Context) => { + const { pathname, searchParams } = context.url; + if (pathname === "/b") { + const { promise, resolve } = Promise.withResolvers(); + waiting.push({ + response: context.response, + reply: (): void => { + resolve(null); + }, + }); + await promise; + await context.send("B"); + return; + } + const other = waiting.shift() as IWaiting; + const form = searchParams.get("form") as string; + const track = (error: unknown): void => { + rejected.push(`${form}:${(error as { code?: string }).code}`); + }; + const { response } = context; + response.statusMessage = "bad\n"; + const source = new PassThrough(); + if (form === "promise") { + pipelineAsync(source, response).catch(track); + } else { + pipeline(source, response, track); + } + if (searchParams.has("foreign")) { + new PassThrough().pipe(other.response); + } + source.end(); + setTimeout(() => { + other.reply(); + }, 20); + await never(); +}); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +/** Sends a request on a new connection; resolves with what it has received */ +function request(path: string): { received: Promise } { + const socket = connect(port, "127.0.0.1"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + const received = (async (): Promise => { + await once(socket, "connect"); + socket.write(`GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`); + const closed = once(socket, "close").then(() => "closed"); + const state = await Promise.race([ + closed, + new Promise((resolve) => { + setTimeout(resolve, 300, "open"); + }), + ]); + socket.destroy(); + const statuses = data.match(/HTTP\/1\.1 \d{3}/gu) ?? []; + const body = data.slice(data.indexOf("\r\n\r\n") + 4); + return `${statuses.map((line) => line.slice(9)).join("+")}(${String(state)}):${body}`; + })(); + return { received }; +} + +const results: string[] = []; +for (const form of ["promise", "callback"]) { + for (const foreign of [true, false]) { + const b = request("/b"); + await pollUntil(() => waiting.length > 0); + const a = request(`/a?form=${form}${foreign ? "&foreign" : ""}`); + results.push( + `${form}${foreign ? "+foreign" : ""} A=${await a.received} B=${await b.received}`, + ); + } +} +server.close(); +process.stdout.write(`${results.join("\n")}\n${rejected.sort().join(" ")}\n`); diff --git a/packages/binden/test/fixtures/guard-refusals.ts b/packages/binden/test/fixtures/guard-refusals.ts new file mode 100644 index 00000000..30ab2f13 --- /dev/null +++ b/packages/binden/test/fixtures/guard-refusals.ts @@ -0,0 +1,186 @@ +// Run by `guard-refusals.test.ts`, without a handler of the process: a +// `Readable` whose listener of `newListener` throws for the listener of +// `error` of its guard (the second one: `guard`), or for every one (`every`), +// whose destroy fails 20 ms later. A source whose guard cannot be installed +// keeps the listener of `error` of its send, and is destroyed without the +// error that decides the send (a body longer than its `Content-Length`, +// sent whole, cancelled at the deadline, or destroyed by the application +// later): its late error is logged once. One that refuses every listener of +// `error` is never destroyed (failed by its own `on()`, or released to +// `HEAD`): it is left untouched (never paused). One whose own `on()` emits +// an error once the send listens (`/emitted`) keeps the listener that has +// heard it. Nothing may crash the process. One line per path: ` +// destroyed= paused= +// errored= errors=`, then the late errors +// logged, and the counts of the records of the guards refused and of the +// sources left. +import { Binden, type Context } from "../../index.js"; +import { type IncomingMessage, request } from "node:http"; +import { capture, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +/** A source of a path, which refuses the listeners of its `error` */ +interface IRefusing { + readonly refuses: "every" | "guard"; + /** What it pushes once read (`null`: nothing, it never ends) */ + readonly body: string | null; + readonly autoDestroy?: boolean; + /** + * Whether its own `on()` emits an `error` once it has added the first + * listener of `error` (of the send, which hears it then) + */ + readonly emits?: boolean; +} + +const specs: Readonly> = { + "/every": { refuses: "every", body: null }, + "/guard": { refuses: "guard", body: "too long" }, + "/ended": { refuses: "guard", body: "ok" }, + "/later": { refuses: "guard", body: "ok", autoDestroy: false }, + "/deadline": { refuses: "guard", body: null }, + "/head": { refuses: "every", body: "ok" }, + "/emitted": { refuses: "guard", body: null, emits: true }, +}; + +/** + * A `Readable` that refuses the listeners of its `error` of a spec, whose + * destroy fails 20 ms later (with the path) + */ +function refusing( + path: string, + { refuses, body, autoDestroy = true, emits = false }: IRefusing, +): Readable { + let listeners = 0; + const source = new Readable({ + autoDestroy, + read(): void { + if (body !== null) { + this.push(body); + this.push(null); + } + }, + destroy(_error, callback): void { + setTimeout(callback, 20, new Error(`Late failure (${path})`)); + }, + }); + source.on("newListener", (event: string) => { + if (event === "error") { + listeners += 1; + if (refuses === "every" || listeners === 2) { + throw new Error(`No listener of error #${listeners}`); + } + } + }); + if (emits) { + Object.assign(source, { + on(event: string, listener: () => void): Readable { + Readable.prototype.on.call(source, event, listener); + if (event === "error" && listeners === 1) { + source.emit("error", new Error("Emitted at once")); + } + return source; + }, + }); + } + return source; +} + +/** How a send has rejected: the code of its error, or else its message */ +function rejection(error: unknown): string { + const { code, message } = error as { code?: unknown; message?: unknown }; + return typeof code === "string" ? code : String(message); +} + +/** The sources, and how their sends have settled, by path */ +const sources = new Map(); +const outcomes = new Map(); + +const captured = capture(); +const middleware = async (context: Context): Promise => { + const { pathname } = context.url; + const source = refusing(pathname, specs[pathname]); + sources.set(pathname, source); + if (pathname === "/guard") { + context.response.setHeader("Content-Length", 2); + } + const outcome = await context.send(source).then( + () => "resolved", + (error: unknown) => `rejected:${rejection(error)}`, + ); + outcomes.set(pathname, outcome); + if (pathname === "/later") { + // By the application, once the send has settled: its destroy fails + setTimeout(() => { + source.destroy(); + }, 5); + } + if (!context.response.writableEnded) { + throw new Error("Replied"); + } +}; +const options = { logger: captured.logger, log_error_details: true }; +const apps = [ + new Binden({ ...options, timeout: 0 }).use(middleware), + new Binden({ ...options, timeout: 50 }).use(middleware), +]; +const ports: number[] = []; +for (const app of apps) { + const server = app.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + ports.push((server.address() as AddressInfo).port); +} + +const lines: string[] = []; +for (const path of Object.keys(specs)) { + const port = path === "/deadline" ? ports[1] : ports[0]; + const method = path === "/head" ? "HEAD" : "GET"; + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, method, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + lines.push(`${path} ${response.statusCode ?? 0}`); +} + +/** The messages of the late errors that have been logged (whatever the message of their record) */ +const late = (): string[] => + captured.records + .filter(({ level }) => level === 20) + .map( + ({ failure }) => (failure as { message?: unknown } | undefined)?.message, + ) + .filter( + (message): message is string => + typeof message === "string" && message.startsWith("Late failure"), + ); +// Every late error (`/guard`, `/ended`, `/later`, `/deadline` and +// `/emitted`), then a +// while for any other one +await pollUntil(() => late().length >= 5, 5_000).catch(() => null); +await new Promise((resolve) => { + setTimeout(resolve, 50); +}); +const count = (message: string): number => + captured.records.filter(({ msg }) => msg === message).length; +process.stdout.write( + `${lines + .map((line) => { + const [path = ""] = line.split(" "); + const source = sources.get(path) as Readable; + const errored = source.errored?.message ?? null; + return `${line} ${outcomes.get(path)} destroyed=${source.destroyed} errored=${errored} paused=${source.isPaused()} errors=${source.listenerCount("error")}`; + }) + .join("\n")}\n` + + `${late().sort().join(", ")}\n` + + `refused=${count("The release of a stream has failed")} left=${count("A stream could not be released")}\n`, +); +process.exit(0); diff --git a/packages/binden/test/fixtures/guard-removal-logs.ts b/packages/binden/test/fixtures/guard-removal-logs.ts new file mode 100644 index 00000000..0805b888 --- /dev/null +++ b/packages/binden/test/fixtures/guard-removal-logs.ts @@ -0,0 +1,36 @@ +// Run by `guard-removals.test.ts`: the guard of a source (see `guard()`) +// leaves on the turn after its close, and each removal of its listeners +// throws (a listener of the application of its `removeListener`), and so does +// the logger that logs them (the report, given the message of a removal): +// that must not crash the process from the turn of the guard. Prints the +// listeners of `error` and `close` left on the source. +import { emitterMethods, guard } from "../../src/stream-like.js"; +import { Readable } from "node:stream"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const source = new Readable({ + read(): void { + // Never read + }, +}); +guard(source, emitterMethods, (_error: unknown, message?: string) => { + if (typeof message !== "undefined") { + throw new Error("The logger has failed"); + } +}); +source.on("removeListener", () => { + throw new Error("No removal"); +}); +source.destroy(); +for (let turn = 0; turn < 3; turn += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} +process.stdout.write( + `left ${source.listenerCount("error")} ${source.listenerCount("close")}\n`, +); diff --git a/packages/binden/test/fixtures/guarded-records.ts b/packages/binden/test/fixtures/guarded-records.ts new file mode 100644 index 00000000..3be719b1 --- /dev/null +++ b/packages/binden/test/fixtures/guarded-records.ts @@ -0,0 +1,191 @@ +// Run by `process.test.ts`, without process handlers: binden writes its own +// records by the private paths of its loggers, and what getting the logger +// or the fields of a record throws drops that record only. A middleware +// replaces the methods (and `child()`) of `context.log` with ones that throw, +// or defines a `log` getter on the context that throws; a middleware returns +// a context of a subclass whose `log` (or `id`) throws; the application's +// logger gives a child whose methods throw; and `child()` (with the methods) +// is replaced after the construction of the application, on a logger of +// binden and on the application's own logger. Each time, every pipelined +// reply arrives (the access log, a failure, a deadline), and the records +// reach the logger unless getting it throws. +import { type AddressInfo, Socket, connect } from "node:net"; +import { + Binden, + BindenRequest, + BindenResponse, + Context, + type IMiddleware, +} from "../../index.js"; +import type { ILogger } from "@binden/logger"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const methods = ["fatal", "error", "warn", "info", "debug", "trace"] as const; +const messages: string[] = []; +const thrower = (): never => { + throw new RangeError("A failing logger"); +}; + +/** A logger that records the messages (and throws once it has, with `fails`) */ +function recorder(fails = false): ILogger { + const record = (message: string): void => { + messages.push(message); + if (fails) { + thrower(); + } + }; + const logger: ILogger = { + fatal: record, + error: record, + warn: record, + info: record, + debug: record, + trace: record, + child: () => logger, + }; + return logger; +} + +/** Replaces the methods and `child()` of a logger with ones that throw */ +function replace(logger: ILogger): void { + for (const method of [...methods, "child" as const]) { + Object.assign(logger, { [method]: thrower }); + } +} + +/** A context whose `log` throws */ +class Unlogged extends Context { + // eslint-disable-next-line class-methods-use-this -- a getter that fails + public override get log(): ILogger { + return thrower(); + } +} + +/** A context whose `id` throws (and so does its `log`, which reads it) */ +class Unnamed extends Context { + // eslint-disable-next-line class-methods-use-this -- a getter that fails + public override get id(): string { + return thrower(); + } +} + +/** A logger of binden (`context.log`), whose logger is a recorder */ +function bindenLogger(): ILogger { + const message = new BindenRequest(new Socket()); + return new Context({ + request: message, + response: new BindenResponse(message), + logger: recorder(), + }).log; +} + +/** A mode that changes nothing in the middleware */ +const none = (): void => { + // Nothing +}; + +const modes: Record void]> = + { + replaced: [ + recorder(), + (context): void => { + replace(context.log); + }, + (): void => { + // Nothing after the construction + }, + ], + getter: [ + recorder(), + (context): void => { + Object.defineProperty(context, "log", { get: thrower }); + }, + (): void => { + // Nothing after the construction + }, + ], + subclass: [ + recorder(), + ({ request, response }): Context => new Unlogged({ request, response }), + (): void => { + // Nothing after the construction + }, + ], + id: [ + recorder(), + ({ request, response }): Context => new Unnamed({ request, response }), + (): void => { + // Nothing after the construction + }, + ], + "throwing-child": [ + { ...recorder(), child: (): ILogger => recorder(true) }, + none, + (): void => { + // Nothing after the construction + }, + ], + "replaced-binden": [bindenLogger(), none, replace], + "replaced-application": [ + recorder(), + none, + (logger): void => { + logger.child = thrower; + }, + ], + }; + +const requests = ["/ok", "/fails", "/hangs", "/next"] + .map((path) => `GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`) + .join(""); + +const results: string[] = []; +for (const [name, [logger, mode, after]] of Object.entries(modes)) { + const app = new Binden({ logger, access_log: true, timeout: 50 }); + after(logger); + app.use(mode); + app.use(async (context) => { + const { pathname } = context.url; + if (pathname === "/fails") { + throw new Error("A failure"); + } else if (pathname === "/hangs") { + await new Promise(() => { + // Never settles: the deadline replies + }); + } + await context.send("ok"); + }); + const server = app.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + messages.length = 0; + const socket = connect(port, "127.0.0.1"); + let data = ""; + const replied = new Promise((resolve) => { + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + if ((data.match(/HTTP\/1\.1 \d+/gu) ?? []).length === 4) { + resolve(); + } + }); + }); + socket.write(requests); + await replied; + socket.destroy(); + server.close(); + await once(server, "close"); + const statuses = (data.match(/HTTP\/1\.1 \d+/gu) ?? []).map((line) => + line.slice(9), + ); + const completed = messages.filter( + (message) => message === "The request has completed", + ).length; + const failed = messages.includes("The request has failed"); + results.push(`${name} ${statuses.join(" ")} ${completed} ${failed}`); +} +process.stdout.write(`${results.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/hangs.ts b/packages/binden/test/fixtures/hangs.ts new file mode 100644 index 00000000..216ccb13 --- /dev/null +++ b/packages/binden/test/fixtures/hangs.ts @@ -0,0 +1,80 @@ +// 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 { Binden, BindenError, type Context } from "../../index.js"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +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 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, "127.0.0.1"); +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, host: "127.0.0.1", 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/fixtures/kept-destroy-errors.ts b/packages/binden/test/fixtures/kept-destroy-errors.ts new file mode 100644 index 00000000..a853cd83 --- /dev/null +++ b/packages/binden/test/fixtures/kept-destroy-errors.ts @@ -0,0 +1,117 @@ +// Run by `kept-destroy-errors.test.ts` with `--expose-gc`: a stream-like +// source that keeps the error that it is destroyed with (as minipass 7 keeps +// the error that it has emitted), which the application keeps, must not keep +// the request nor the response of a send that binden releases: the error +// that binden destroys it with has its stack formatted, which then holds +// none of the objects of its frames. Modes: `gone` (the client goes away +// once it has the head), `deadline` (once the head is sent), and `writer` +// (another writer ends the response). Prints how many of the requests and +// of the responses are still alive once collected, how many sends have +// settled, and how many sources keep an error. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { capture } from "../helpers.js"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +/** A stream-like source that keeps the error that it is destroyed with, and emits it */ +class Keeping extends EventEmitter { + public kept: unknown = null; + + public destroyed = false; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: unknown): this { + this.destroyed = true; + this.kept = error ?? null; + if (typeof error !== "undefined") { + this.emit("error", error); + } + return this; + } +} + +const [how = "gone"] = process.argv.slice(2); +const requests = 30; +const sources: Keeping[] = []; +const references: WeakRef[] = []; +let settled = 0; +// The report of the guards logs through it +const { logger } = capture(); +const app = new Binden({ logger, timeout: how === "deadline" ? 30 : 0 }).use( + async (context: Context) => { + const source = new Keeping(); + sources.push(source); + references.push( + new WeakRef(context.request), + new WeakRef(context.response), + ); + // The head is sent with its first chunk + setImmediate(() => { + source.emit("data", Buffer.from("x")); + }); + if (how === "writer") { + setTimeout(() => { + context.response.end(); + }, 5); + } + await context.send(source).catch(() => null); + settled += 1; + }, +); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +await Promise.all( + Array.from( + { length: requests }, + () => + new Promise((resolve) => { + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => null); + socket.on("data", () => { + if (how === "gone") { + socket.destroy(); + } + }); + socket.once("close", () => { + resolve(); + }); + socket.write("GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"); + }), + ), +); +/** Whether every send has settled (they settle once their responses close) */ +const allSettled = (): boolean => settled === requests; +for (let turn = 0; turn < 200 && !allSettled(); turn += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); +} +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 6; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); + gc(); +} +const alive = references.filter((reference) => reference.deref()).length; +const kept = sources.filter(({ kept: error }) => error instanceof Error).length; +process.stdout.write( + `${JSON.stringify({ alive, settled, kept, sources: sources.length })}\n`, +); diff --git a/packages/binden/test/fixtures/kept-guard-retention.ts b/packages/binden/test/fixtures/kept-guard-retention.ts new file mode 100644 index 00000000..5c6f9806 --- /dev/null +++ b/packages/binden/test/fixtures/kept-guard-retention.ts @@ -0,0 +1,100 @@ +// Run by `kept-guards.test.ts` with `--expose-gc`: the guards that stay on +// stream-like sources whose state cannot tell that they have closed +// (`PassThrough`s of `readable-stream` 3, and emitters of their own whose +// destroy emits `close`), cancelled at the deadline, must not keep the +// responses: they hold nothing but the report of a late error. Prints +// whether the responses are collected, and the listeners of `error` and +// `close` left on the sources (one each, of its guard). +import { Agent, get } from "node:http"; +import { Binden, BindenResponse, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { capture } from "../helpers.js"; +import { createRequire } from "node:module"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const require = createRequire(import.meta.url); + +/** A `PassThrough` of `readable-stream` 3 */ +type IPassThrough = new () => EventEmitter; + +const { PassThrough } = require("readable-stream-v3") as { + PassThrough: IPassThrough; +}; + +/** An emitter of its own whose destroy emits `close` */ +class Closing extends EventEmitter { + public closed = false; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(): this { + process.nextTick(() => { + this.closed = true; + this.emit("close"); + }); + return this; + } +} + +const requests = 400; +const sources: EventEmitter[] = []; +// The report of the guards logs through it +const { logger } = capture(); +const app = new Binden({ logger, timeout: 10 }).use( + async (context: Context) => { + const source = sources.length % 2 === 0 ? new PassThrough() : new Closing(); + sources.push(source); + // @ts-expect-error -- both are stream-like sources + await context.send(source); + }, +); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const agent = new Agent({ keepAlive: true, maxSockets: 8 }); +const request = (): Promise => + new Promise((resolve, reject) => { + get({ port, agent, host: "127.0.0.1" }, (response) => { + response.resume().once("end", resolve); + }).once("error", reject); + }); +for (let sent = 0; sent < requests; sent += 8) { + await Promise.all(Array.from({ length: 8 }, request)); +} +agent.destroy(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const left = queryObjects(BindenResponse, { format: "count" }); +// Destroyed (each `PassThrough` emits its `close`), or closed +const closed = sources.filter( + (source) => + Reflect.get(source, "destroyed") === true || + Reflect.get(source, "closed") === true, +).length; +const listeners = ["data", "end", "error", "close"].map((event) => + sources.reduce((count, source) => count + source.listenerCount(event), 0), +); +process.stdout.write( + `${left < requests / 10 ? "collected" : `kept ${left}`} ${closed} ${listeners.join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/kept-guards.ts b/packages/binden/test/fixtures/kept-guards.ts new file mode 100644 index 00000000..00d08bdf --- /dev/null +++ b/packages/binden/test/fixtures/kept-guards.ts @@ -0,0 +1,272 @@ +// Run by `kept-guards.test.ts`, without a handler of the process: a +// stream-like source whose state cannot tell that it has closed (of +// `readable-stream` 2 or 3, a transform of `through2` 4, or an emitter of its +// own) keeps its guard once it has closed, so that an error that it emits +// later (a write of the application once binden has destroyed it, a destroy +// with an error, or an error of its own) is logged once, and never crashes +// the process. Prints, by case, its status, whether such errors have come, +// whether each has been logged once, and the listeners of `error` and +// `close` left on the source (of its guard). +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context, releasedFailure } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { capture, pollUntil } from "../helpers.js"; +import { createRequire } from "node:module"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +const require = createRequire(import.meta.url); + +/** A stream of `readable-stream` */ +interface IStream extends EventEmitter { + write(chunk: string): boolean; + destroy(error?: Error): void; + pause(): this; + resume(): this; +} + +/** The streams of a version of `readable-stream` */ +interface ILibrary { + readonly PassThrough: new () => IStream; + readonly Transform: new (options: { + transform( + chunk: Buffer, + encoding: string, + callback: (error: null, chunk: Buffer) => void, + ): void; + }) => IStream; + readonly Readable: new (options: { read(): void }) => IStream; +} + +const [v2, v3] = ["2", "3"].map( + (version) => require(`readable-stream-v${version}`) as ILibrary, +) as [ILibrary, ILibrary]; + +/** Whether an error is one that a source emits once it has closed */ +type ILater = (error: unknown) => boolean; + +/** A case: its source, the errors that it emits later, and its request */ +interface ICase { + readonly source: EventEmitter; + /** Whether an error is one that the source emits once it has closed */ + readonly later: ILater; + /** The errors that the source has emitted (counted without a listener) */ + readonly errors: unknown[]; + /** The timer of the application that writes to it, if any */ + timer?: NodeJS.Timeout; + /** The id of its request */ + id?: string; +} + +const cases = new Map(); + +/** Counts the errors that a source emits, without listening to them */ +function counted(source: EventEmitter, later: ILater): ICase { + const errors: unknown[] = []; + const emit = source.emit.bind(source); + Object.assign(source, { + emit(event: string, ...args: unknown[]): boolean { + if (event === "error") { + errors.push(args[0]); + } + return emit(event, ...args); + }, + }); + return { source, later, errors }; +} + +/** The code of an error */ +function codeOf(error: unknown): unknown { + return (error as { code?: unknown } | null)?.code; +} + +/** The message of an error */ +function messageOf(error: unknown): unknown { + return (error as { message?: unknown } | null)?.message; +} + +/** + * A feed of events written on a timer of the application, which never + * listens to its errors: its first event before it is sent (which commits + * the headers once it is read) + */ +function feed(stream: IStream, later: ILater): ICase { + const fed = counted(stream, later); + let event = 0; + stream.write("data: 0\n\n"); + fed.timer = setInterval(() => { + event += 1; + stream.write(`data: ${event}\n\n`); + }, 10); + return fed; +} + +/** The case of each path */ +const sources: Record ICase> = { + // A transform of `through2` 4 (of `readable-stream` 3), as an SSE feed + "/through2-deadline": () => + feed( + new v3.Transform({ + transform(chunk, _encoding, callback): void { + callback(null, chunk); + }, + }), + (error) => codeOf(error) === "ERR_STREAM_DESTROYED", + ), + "/through2-gone": () => + feed( + new v3.Transform({ + transform(chunk, _encoding, callback): void { + callback(null, chunk); + }, + }), + (error) => codeOf(error) === "ERR_STREAM_DESTROYED", + ), + "/v2-gone": () => + feed( + new v2.PassThrough(), + (error) => messageOf(error) === "write after end", + ), + "/v3-gone": () => + feed( + new v3.PassThrough(), + (error) => codeOf(error) === "ERR_STREAM_DESTROYED", + ), + // An emitter of its own that emits `close`, then an error (at once, or + // 20 ms later) + ...Object.fromEntries( + [0, 20].map((ms) => [ + `/custom-${ms}`, + (): ICase => { + const source = Object.assign(new EventEmitter(), { + pause: (): null => null, + resume: (): null => null, + }); + setTimeout(() => { + source.emit("close"); + setTimeout(() => { + source.emit("error", new Error("Closed")); + }, ms); + }, 10); + return counted(source, (error) => messageOf(error) === "Closed"); + }, + ]), + ), + // A `Readable` of `readable-stream` 3 whose `newListener` refuses a + // listener of the send, destroyed with an error later + ...Object.fromEntries( + ["end", "data"].map((event) => [ + `/refused-${event}`, + (): ICase => { + const source = new v3.Readable({ + read(): void { + // Nothing to read + }, + }); + source.on("newListener", (name: string) => { + if (name === event) { + throw new Error(`No listener of ${event}`); + } + }); + setTimeout(() => { + source.destroy(new Error("I/O failure")); + }, 50); + return counted(source, (error) => messageOf(error) === "I/O failure"); + }, + ]), + ), +}; + +const captured = capture(); + +/** An application whose middleware sends the source of the path */ +function application(timeout: number): Binden { + return new Binden({ + logger: captured.logger, + log_error_details: true, + timeout, + }).use(async (context: Context) => { + const { pathname } = context.url; + const create = sources[pathname]; + if (typeof create === "undefined") { + throw new Error(`No case ${pathname}`); + } + const created = create(); + created.id = context.id; + cases.set(pathname, created); + context.response.setHeader("Content-Type", "text/event-stream"); + // @ts-expect-error -- every source is a stream-like one + await context.send(created.source); + }); +} + +/** The status of a request on a new connection, which goes away once its first chunk has come, or once closed */ +async function status(port: number, path: string): Promise { + const socket = connect(port, "127.0.0.1"); + let received = ""; + socket.setEncoding("latin1").on("error", () => null); + socket.on("data", (chunk: string) => { + received += chunk; + if (path.endsWith("-gone") && /\r\n\r\n.*data: /su.test(received)) { + socket.destroy(); + } + }); + socket.write(`GET ${path} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`); + await once(socket, "close"); + return /^HTTP\/1\.1 (?\d{3})/u.exec(received)?.groups?.code ?? "none"; +} + +const lines: string[] = []; +for (const [timeout, paths] of [ + [100, ["/through2-deadline"]], + [ + 0, + [ + "/through2-gone", + "/v2-gone", + "/v3-gone", + "/custom-0", + "/custom-20", + "/refused-end", + "/refused-data", + ], + ], +] as const) { + const server = application(timeout).createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + for (const path of paths) { + const code = await status(port, path); + const sent = cases.get(path) as ICase; + // Two later errors of each feed, one of the others + const expected = sent.timer ? 2 : 1; + await pollUntil( + () => sent.errors.filter(sent.later).length >= expected, + 5000, + ).catch(() => null); + clearInterval(sent.timer); + // Each logged within the turn of its error + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + const later = sent.errors.filter(sent.later).length; + const logged = captured.records.filter( + ({ msg, trace_id, failure }) => + msg === releasedFailure && + trace_id === sent.id && + sent.later({ + code: (failure as { code?: unknown }).code, + message: (failure as { message?: unknown }).message, + }), + ).length; + lines.push( + `${path} ${code} later=${later >= expected} ${logged === later ? "each logged once" : `logged ${logged} of ${later}`} guard=${sent.source.listenerCount("error")}/${sent.source.listenerCount("close")}`, + ); + } + server.closeAllConnections(); + server.close(); +} +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/left-listeners.ts b/packages/binden/test/fixtures/left-listeners.ts new file mode 100644 index 00000000..144141ed --- /dev/null +++ b/packages/binden/test/fixtures/left-listeners.ts @@ -0,0 +1,91 @@ +// Run by `stream-like-left-listeners.test.ts` with `--expose-gc`: a +// stream-like source whose `removeListener()` throws for one event (the +// argument: `data`, `end` or `close`) keeps the listener of that event that +// the send has added, and the application keeps the sources: those listeners +// must not keep the responses (nor anything of their sends). Prints whether +// the responses are collected, and the listeners of that event left on the +// sources. +import { Agent, get } from "node:http"; +import { Binden, BindenResponse, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { capture } from "../helpers.js"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [left = "data"] = process.argv.slice(2); + +/** A stream-like source whose `removeListener()` throws for one event */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(): this { + return this; + } + + public override removeListener( + event: string, + listener: (...args: unknown[]) => void, + ): this { + if (event === left) { + throw new Error("The listener cannot be removed"); + } + return super.removeListener(event, listener); + } +} + +const requests = 200; +const sources: Source[] = []; +// The report of the guards logs through it +const { logger } = capture(); +const app = new Binden({ logger }).use(async (context: Context) => { + const source = new Source(); + sources.push(source); + const sent = context.send(source); + source.emit("data", Buffer.from("ok")); + source.emit("end"); + await sent; +}); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const agent = new Agent({ keepAlive: true, maxSockets: 8 }); +const request = (): Promise => + new Promise((resolve, reject) => { + get({ port, agent, host: "127.0.0.1" }, (response) => { + response.resume().once("end", resolve); + }).once("error", reject); + }); +for (let sent = 0; sent < requests; sent += 8) { + await Promise.all(Array.from({ length: 8 }, request)); +} +agent.destroy(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const kept = queryObjects(BindenResponse, { format: "count" }); +const listeners = sources.reduce( + (count, source) => count + source.listenerCount(left), + 0, +); +process.stdout.write( + `${kept < requests / 10 ? "collected" : `kept ${kept}`} ${listeners}\n`, +); diff --git a/packages/binden/test/fixtures/linear-media-type.ts b/packages/binden/test/fixtures/linear-media-type.ts new file mode 100644 index 00000000..42204886 --- /dev/null +++ b/packages/binden/test/fixtures/linear-media-type.ts @@ -0,0 +1,32 @@ +// Run in a worker thread by `headers/linear.test.ts`: `response.type()` of +// inputs that an exponential regular expression would never finish (the +// worker is then terminated), with the CPU time that each takes in the worker +// (see `cpuSince()` of the test: unlike the time of the clock, it does not +// grow while other processes load the machine) +import { BindenResponse } from "../../index.js"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; +import { parentPort } from "node:worker_threads"; + +const response = new BindenResponse(new IncomingMessage(new Socket())); +const inputs = [ + // Each ` ;` doubled the time (71 characters took 73 s) + `text/plain${" ;".repeat(40)}\x01`, + `text/plain${" ;".repeat(7500)}\x01`, + `text/plain;${"\t".repeat(15_000)}a=b\x01`, + `text/plain; a="${'\\"'.repeat(7500)}`, + `text/plain; a=${"b".repeat(15_000)} \x01`, + `${" ".repeat(15_000)}text/plain${" ".repeat(15_000)}\x01`, +]; +const results = inputs.map((input) => { + const start = process.threadCpuUsage(); + let outcome = "accepted"; + try { + response.type(input); + } catch (error: unknown) { + outcome = (error as Error).name; + } + const { user, system } = process.threadCpuUsage(start); + return { outcome, ms: (user + system) / 1000 }; +}); +parentPort?.postMessage(results); diff --git a/packages/binden/test/fixtures/native-late-errors.ts b/packages/binden/test/fixtures/native-late-errors.ts new file mode 100644 index 00000000..aaba5f8c --- /dev/null +++ b/packages/binden/test/fixtures/native-late-errors.ts @@ -0,0 +1,99 @@ +// Run by `settled-sends.test.ts`: the destroy of a Node.js `Readable` may fail +// later, without a listener of the application, once its send no longer +// listens to it. It must not crash the process: when the application has +// destroyed it before the send (which rejects), and when it destroys itself +// once it has ended (`autoDestroy`), before the send or while it is sent. Its +// late error is logged at `debug`, and its guard is removed once it has +// closed. +import { Binden, releasedFailure } from "../../index.js"; +import { type IncomingMessage, request } from "node:http"; +import { capture, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +/** A readable that ends once read, whose destroy fails 20 ms later */ +function failing(pathname: string): Readable { + return new Readable({ + read(): void { + this.push(null); + }, + destroy(_error, callback): void { + setTimeout(callback, 20, new Error(`Failed later (${pathname})`)); + }, + }); +} + +/** The sources, by path */ +const sources = new Map(); +/** What each send has settled with, by path */ +const sent = new Map(); + +const captured = capture(); +const app = new Binden({ + logger: captured.logger, + log_error_details: true, +}).use(async (context) => { + const { pathname } = context.url; + const source = failing(pathname); + sources.set(pathname, source); + if (pathname === "/destroyed") { + source.destroy(); + } else if (pathname === "/ended") { + // Its destroy is pending + source.resume(); + await once(source, "end"); + } + await context.send(source).then( + () => { + sent.set(pathname, "resolved"); + }, + (error: unknown) => { + sent.set(pathname, String((error as { code?: unknown }).code)); + throw error; + }, + ); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const paths = ["/destroyed", "/ended", "/active"]; +const statuses: number[] = []; +for (const path of paths) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +/** The late errors that have been logged */ +const reported = (): string[] => + captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => String((failure as { message?: unknown }).message)); +await pollUntil(() => reported().length >= paths.length); +// Once the guards of the sources, which have closed, are removed +await new Promise((resolve) => { + setTimeout(resolve, 20); +}); +server.close(); +process.stdout.write( + `${statuses.join(" ")}\n${paths.map((path) => sent.get(path)).join(" ")}\n${reported().sort().join(", ")}\n${paths + .map((path) => + ["data", "end", "error", "close"] + .map((event) => sources.get(path)?.listenerCount(event)) + .join(""), + ) + .join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/native-retention.ts b/packages/binden/test/fixtures/native-retention.ts new file mode 100644 index 00000000..68f5cffc --- /dev/null +++ b/packages/binden/test/fixtures/native-retention.ts @@ -0,0 +1,142 @@ +// Run by `retention.test.ts` with `--expose-gc`: a Node.js `Readable` that the +// application keeps once its send has settled must not keep the response. +// The send removes every listener that it has added to the source, and the +// guard that stays on it (until the turn after its close) holds nothing but +// the report of a late error. Modes: `sent` (sent in full), `ended` (ended and +// closed before the send) and `cancelled` (at the deadline, with a destroy +// that does not complete). With `bound`, the listeners of each source are +// bound by a wrapper of its listener methods (see `bind()`). +// Prints whether the responses are collected, and the listeners left on the +// sources (of `data`, `end`, `error` and `close`). +import { Agent, get } from "node:http"; +import { Binden, BindenResponse, type Context } from "../../index.js"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { capture } from "../helpers.js"; +import { once } from "node:events"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [mode = "sent", binding = "unbound"] = process.argv.slice(2); +const requests = 400; +const sources: Readable[] = []; + +/** A method of an emitter */ +type IMethod = (this: Readable, ...args: unknown[]) => unknown; + +/** + * Binds the listeners of a source as a wrapper of the listener methods of an + * emitter may (e.g. an instrumentation that binds the listeners to a context): + * its own `on()`, `addListener()` and `prependListener()` add a wrapper of + * each listener in its place, which its own `removeListener()` and `off()` + * find. Its `once()` is left as it is: it adds a wrapper of its own through + * `on()`, which they do not find (OpenTelemetry also patches `once()`, but a + * wrapper need not), so the send must not listen with `once()` + */ +function bind(source: Readable): Readable { + const wrappers = new Map>(); + for (const name of ["on", "addListener", "prependListener"]) { + const add = Reflect.get(source, name) as IMethod; + Object.defineProperty(source, name, { + configurable: true, + writable: true, + value(this: Readable, event: string, listener: IMethod): unknown { + function bound(this: unknown, ...args: unknown[]): unknown { + return Reflect.apply(listener, this, args); + } + const map = wrappers.get(event) ?? new WeakMap(); + wrappers.set(event, map.set(listener, bound)); + return Reflect.apply(add, this, [event, bound]); + }, + }); + } + for (const name of ["removeListener", "off"]) { + const remove = Reflect.get(source, name) as IMethod; + Object.defineProperty(source, name, { + configurable: true, + writable: true, + value(this: Readable, event: string, listener: IMethod): unknown { + const bound = wrappers.get(event)?.get(listener) ?? listener; + return Reflect.apply(remove, this, [event, bound]); + }, + }); + } + return source; +} + +/** The source of a send, as the mode makes it */ +async function created(): Promise { + if (mode === "sent") { + return Readable.from(["x"]); + } else if (mode === "ended") { + const source = new Readable({ + read(): void { + this.push(null); + }, + }); + source.resume(); + await once(source, "close"); + return source; + } + return new Readable({ + read(): void { + // Its first chunk never comes + }, + destroy(error, callback): void { + setTimeout(callback, 60_000, error).unref(); + }, + }); +} + +/** The source of a send, as the mode makes it (bound with `bound`) */ +async function sourceOf(): Promise { + const source = await created(); + return binding === "bound" ? bind(source) : source; +} + +// The report of the guards logs through it +const { logger } = capture(); +const app = new Binden({ + logger, + ...(mode === "cancelled" ? { timeout: 10 } : {}), +}).use(async (context: Context) => { + const source = await sourceOf(); + sources.push(source); + await context.send(source); +}); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const agent = new Agent({ keepAlive: true, maxSockets: 8 }); +const request = (): Promise => + new Promise((resolve, reject) => { + get({ port, agent, host: "127.0.0.1" }, (response) => { + response.resume().once("end", resolve); + }).once("error", reject); + }); +for (let sent = 0; sent < requests; sent += 8) { + await Promise.all(Array.from({ length: 8 }, request)); +} +agent.destroy(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const left = queryObjects(BindenResponse, { format: "count" }); +const listeners = ["data", "end", "error", "close"].map((event) => + sources.reduce((count, source) => count + source.listenerCount(event), 0), +); +process.stdout.write( + `${left < requests / 10 ? "collected" : `kept ${left}`} ${listeners.join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/pipe-failures.ts b/packages/binden/test/fixtures/pipe-failures.ts new file mode 100644 index 00000000..56e9d9df --- /dev/null +++ b/packages/binden/test/fixtures/pipe-failures.ts @@ -0,0 +1,123 @@ +// Run by `process.test.ts`: a native pipe or `pipeline()` into the response +// whose write or end throws (a head that Node.js refuses, a chunk that is not +// bytes) must neither crash the process nor be left to a deadline (there is +// none): the middleware that has piped it never settles, and the request gets +// one `500` before the headers are sent, its connection closed afterwards. +// `pipeline()` rejects with the original error. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { Readable, pipeline } from "node:stream"; +import { once } from "node:events"; +import { pollUntil } from "../helpers.js"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, (error: unknown) => { + process.stderr.write(`${event} ${String(error)}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const invalid = "bad\n"; +const never = (): Promise => + new Promise(() => { + // Never settles + }); +/** The codes of the errors that `pipeline()` has called back with, by route */ +const rejected: string[] = []; +function track(route: string): (error: unknown) => void { + return (error) => { + rejected.push(`${route}:${(error as { code?: string } | null)?.code}`); + }; +} + +const routes: Partial unknown>> = { + "/pipe": ({ response }) => { + response.statusMessage = invalid; + Readable.from(["body"]).pipe(response); + }, + "/pipeline": ({ response }) => { + response.statusMessage = invalid; + pipeline(Readable.from(["body"]), response, track("pipeline")); + }, + "/trailer-pipe": ({ response }) => { + response.setHeader("Trailer", "X-T"); + response.setHeader("Content-Length", "4"); + Readable.from(["body"]).pipe(response); + }, + // Its end (`end()` of the pipe) commits the head + "/empty-pipe": ({ response }) => { + response.statusMessage = invalid; + Readable.from([]).pipe(response); + }, + "/empty-pipeline": ({ response }) => { + response.statusMessage = invalid; + pipeline(Readable.from([]), response, track("empty-pipeline")); + }, + // A chunk that is not bytes, once the headers are sent + "/committed": ({ response }) => { + response.flushHeaders(); + Readable.from([42]).pipe(response); + }, + "/committed-pipeline": ({ response }) => { + response.flushHeaders(); + pipeline(Readable.from([42]), response, track("committed-pipeline")); + }, +}; + +const app = new Binden().use(async (context: Context) => { + routes[context.url.pathname]?.(context); + await never(); +}); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +/** Whether a complete response has come (framed by its `Content-Length`) */ +function complete(data: string): boolean { + const end = data.indexOf("\r\n\r\n"); + const length = /^Content-Length: (?\d+)$/imu.exec(data.slice(0, end)) + ?.groups?.n; + return ( + end !== -1 && + typeof length !== "undefined" && + data.length >= end + 4 + Number(length) + ); +} + +/** + * The status lines received on a connection, and whether it was closed: once + * it has, or 300 ms after a complete response (which a loaded machine may + * delay: within 5 s), judged after a turn of the pending I/O + */ +async function exchange(path: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write(`GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`); + const connection = { closed: false }; + socket.once("close", () => { + connection.closed = true; + }); + await pollUntil(() => connection.closed || complete(data), 5000).catch( + () => null, + ); + await pollUntil(() => connection.closed, 300).catch(() => null); + const state = connection.closed ? "closed" : "open"; + socket.destroy(); + const statuses = data.match(/HTTP\/1\.1 \d{3}/gu) ?? []; + return `${statuses.map((line) => line.slice(9)).join("+")}(${String(state)})`; +} + +const results: string[] = []; +for (const path of Object.keys(routes)) { + results.push(await exchange(path)); +} +server.close(); +process.stdout.write(`${results.join(" ")}\n${rejected.sort().join(" ")}\n`); diff --git a/packages/binden/test/fixtures/public-surface.ts b/packages/binden/test/fixtures/public-surface.ts new file mode 100644 index 00000000..dd286c90 --- /dev/null +++ b/packages/binden/test/fixtures/public-surface.ts @@ -0,0 +1,6864 @@ +// Run by `public-surface.test.ts` in a process of its own: enumerates at run +// time the public surface of the built entries of the five packages (every +// export, every constructor, and every static and prototype member of an +// exported class, by `Reflect.ownKeys()` and the descriptors, symbol keys too, +// the functions that an entry point returns or installs on an object, see +// `returned` and `installed`, and the members of the objects that it returns, +// see `members`), runs a validated baseline call of each entry point (getters +// read, on an instance of each valid call of their constructor, setters set, +// methods called, classes constructed), then the same call with each hostile +// value in each argument position (and the nested positions that its fixture +// names), each on fresh instances, the members of a response and of a context +// also once the head of the response is sent, once it has ended and once it is +// destroyed, and with an object status or a writer that loses the bytes of a +// send (see `states`). Every `Error` that a call throws, or rejects with, and +// every one that a reader of what it has stored throws (see `readers`), must +// have its stack formatted before the caller gets it (recorded by +// `Error.prepareStackTrace`), an error that the test throws itself (from the +// traps of a proxy) must be passed on as itself, what is documented never to +// fail must not throw at all (see `infallible`), a call must run no +// conversion of a value but the documented ones, which the fixtures name, each +// as often as documented (`String()` once, or as often as plain Node.js does, +// see `headerInit` and `nodeCoercion`, also of an object status as the head is +// committed), each option, init and argument must be read at most once, and +// each probed position must be examined by a hostile value (refused, converted +// or read) by the call, unless its fixture says why it is not: one whose valid +// value is a function or an object that the call keeps names the reader that +// uses it later, which must run with a hostile value there and use it, or an +// alternative check, which must run (see `IUntouched`), and so does each one +// where the call accepts a hostile value, but keeps its valid value, a +// function or an object of any class (told by its retention, see +// `retainedBy()` and `kept`: e.g. a `Cookie` that `cookies.add()` keeps until +// the commit reads it), whose reader must also tell each hostile function +// whose result is hostile from the valid one (see `IKept`). The values whose +// retention is told are the application's own, by ownership, not by class: a +// fresh value of the fixture (also an instance, a `Headers`, a `Map`, a view +// or a `URL`), or a stand-in of a function, a plain object or an array that +// every valid call shares (see `standIn()`). Not told, and so not kept by the call, are only: what the +// call returns itself (the caller's, e.g. the error of `formatStack()`), the +// valid value that `late` gives the call (once, from its holder), and, each +// named by its entry point and position, with why and the reader or the +// alternative check that uses it instead (see `held`), a value that the +// environment of the call holds (it survives without the call, e.g. a context +// of an exchange, which its server keeps) or that every valid call shares +// without a stand-in. A logger of binden +// never runs a method that the application has replaced on it for a record of +// its own (see `replaced` and `wrapping()`), and binden never takes a value of +// the test for one of its objects (see `privateFailures`). An allowance that +// permits more than happens is a finding too. +// Prints a JSON report: `{ entries, probed, probes, findings }` (the ids of +// the entry points of the surface, and of those that it has probed), each +// finding as ` | : ` with `--lines` (one per line), and +// `--only=` probes only the entries whose id contains it, and +// `--shard=/` one of `count` shards of them (of about the same +// measured cost, see `costs`, counted from 0), which processes of their own +// probe in parallel: the first shard also checks the fixtures themselves. +// `--timeout=` is how long it may run before it stops itself. +import { type AddressInfo, Server as NetServer, Socket } from "node:net"; +import { EventEmitter, once } from "node:events"; +import { + IncomingMessage, + type Server, + ServerResponse, + createServer as createHttpServer, +} from "node:http"; +import { isNativeError, isPromise, isProxy } from "node:util/types"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { Readable } from "node:stream"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import { setFlagsFromString } from "node:v8"; +import { tmpdir } from "node:os"; + +const args = process.argv.slice(2); +/** + * How long the fixture may run before it stops itself (`--timeout=`): + * its test derives it from the bound at which it kills the process, which + * stays above it, so that a run that is too slow says so + */ +const timeoutMs = Number( + args + .find((arg) => arg.startsWith("--timeout=")) + ?.slice("--timeout=".length) ?? 100_000, +); +const timer = setTimeout(() => { + process.stderr.write(`Timed out after ${String(timeoutMs)} ms\n`); + process.exit(2); +}, timeoutMs); +timer.unref(); + +// A full garbage collection, which tells what a call keeps (see `retainedBy()`) +setFlagsFromString("--expose-gc"); +const collect = runInNewContext("gc") as () => void; + +const HOST = "127.0.0.1"; + +/** How long a promise that a call returns may stay pending */ +const pendingMs = 100; +/** + * How long a promise of a call that does I/O (see `io`) may stay pending + * (e.g. on a loaded machine) + */ +const ioMs = 5000; +/** How long making the receiver or the arguments of a call may take */ +const makeMs = 10_000; +/** How long the cleanup of a probe may take */ +const cleanMs = 5000; +/** + * How many full garbage collections tell what a call keeps (see + * `retainedBy()`), each `keptMs` after the previous one: what it has started + * (e.g. the flush of a send) has settled by then, also on a loaded machine + */ +const keptRounds = 4; +const keptMs = 25; + +/** The errors whose stack V8 has formatted (once it is first read) */ +const formatted = new WeakSet(); +Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + formatted.add(error); + return [ + `${error.name}: ${error.message}`, + ...frames.map((frame) => ` at ${String(frame)}`), + ].join("\n"); + }, +); + +// A plain substring, not a regular expression built from an argument +const only = + args.find((arg) => arg.startsWith("--only="))?.slice("--only=".length) ?? ""; +const [shard = 0, shards = 1] = ( + args.find((arg) => arg.startsWith("--shard="))?.slice("--shard=".length) ?? + "0/1" +) + .split("/") + .map(Number); + +/** A function of the public surface */ +type IFunction = (...values: unknown[]) => unknown; +/** A module namespace, or an object of the surface */ +type IObject = Readonly>; + +/** Imports a module by a specifier that is not resolved at compile time */ +async function load(specifier: string): Promise { + return (await import(specifier)) as IObject; +} + +/** Whether a value is an object or a function (what a proxy can wrap) */ +function isObject(value: unknown): value is object { + return ( + (typeof value === "object" && value !== null) || typeof value === "function" + ); +} + +/** The built entries of the packages, by their names */ +const packages: readonly string[] = [ + "binden", + "@binden/logger", + "@binden/body", + "@binden/compression", + "@binden/multipart", +]; +const entries = new Map(); +for (const name of packages) { + entries.set(name, await load(name)); +} +const binden = entries.get("binden") as IObject; +const pino = (await load("pino")).default as IFunction; + +/** Whether a function is a class */ +function isClass(value: unknown): value is IFunction { + return ( + typeof value === "function" && + Function.prototype.toString.call(value).startsWith("class") + ); +} + +/** + * The prototypes of the language and of Node.js (and `null`), where the + * members of an object of a package end (see `members`) + */ +const foreign = new Set([null]); +/** Adds the prototype chain of a class of the language or of Node.js */ +function addForeign(value: unknown): void { + if (typeof value === "function") { + let prototype: unknown = Reflect.get(value, "prototype"); + while (isObject(prototype) && !foreign.has(prototype)) { + foreign.add(prototype); + prototype = Object.getPrototypeOf(prototype); + } + } +} +for (const key of Reflect.ownKeys(globalThis)) { + // Not a getter of the global object (e.g. a deprecated one) + addForeign(Reflect.getOwnPropertyDescriptor(globalThis, key)?.value); +} +for (const name of [ + "node:buffer", + "node:events", + "node:fs", + "node:http", + "node:https", + "node:net", + "node:stream", + "node:tls", + "node:url", + "node:zlib", +]) { + for (const value of Object.values(await load(name))) { + addForeign(value); + } +} +/** The prototypes of the exported classes, whose members are probed */ +const exported = new Set(); +for (const namespace of entries.values()) { + for (const value of Object.values(namespace)) { + if (isClass(value)) { + exported.add(value.prototype); + } + } +} + +/** The error that the traps of the hostile proxies throw */ +class OwnError extends Error {} + +/** The names of the hostile values */ +const hostileNames = [ + "symbol", + "null-prototype", + "null", + "undefined", + "bigint", + "revoked", + "throwing", + "revoked-function", + "throwing-function", + "class", + "prototype-trap", + "keys-trap", + "has-trap", + "derived", + "transparent", + "counting", + "late", + "late-use", + "late-write", + "child-throws", + "child-other", + "replaced", + "child-replaced", + "log-getter", + "log-subclass", + "derived-result", + "proxy-result", +] as const; +/** The name of a hostile value */ +type IHostileName = (typeof hostileNames)[number]; + +/** A hostile value for an argument position, created for one probe */ +interface IHostile { + readonly name: IHostileName; + readonly value: unknown; + /** How many times its `toString()`, `valueOf()` or `@@toPrimitive` ran */ + conversions(): number; + /** The errors that its traps (or its getter) have thrown */ + readonly thrown: readonly OwnError[]; + /** + * How it is placed in its holder instead of as a value (`late`: a getter + * that gives the valid value once, then throws) + */ + readonly place?: ((valid: unknown) => PropertyDescriptor) | undefined; + /** + * Called once the synchronous part of the call has returned (`late-use`: + * from then on, its traps throw) + */ + readonly arm?: (() => void) | undefined; + /** + * How many times a method that the application has replaced on a logger + * of binden has run (`replaced`): binden logs through the logger that it + * wraps, and makes its children from it, never through those methods + */ + readonly misused?: (() => number) | undefined; +} + +/** The traps of a proxy */ +const trapNames = [ + "apply", + "construct", + "defineProperty", + "deleteProperty", + "getOwnPropertyDescriptor", + "getPrototypeOf", + "has", + "isExtensible", + "ownKeys", + "preventExtensions", + "set", + "setPrototypeOf", +] as const; + +/** The traps that write (see `late-write`) */ +const lateWrites: ReadonlySet = new Set([ + "defineProperty", + "deleteProperty", + "set", +]); + +/** A function that does nothing */ +function noop(): void { + // Nothing +} + +/** The methods of a logger (`ILogger`) */ +const loggerMethods = [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + "child", +] as const; + +/** Whether a value has the methods of a logger (e.g. a valid `logger`) */ +function isLoggerValue(value: unknown): boolean { + return ( + isObject(value) && + !isProxy(value) && + loggerMethods.every( + (method) => typeof Reflect.get(value, method) === "function", + ) + ); +} + +/** Whether a value is a context of binden (e.g. the one of an exchange) */ +function isContextValue(value: unknown): value is object { + return ( + isObject(value) && + !isProxy(value) && + value instanceof (binden.Context as IFunction) + ); +} + +/** A logger whose methods are those given (and whose `child()` is given) */ +function loggerOf( + method: (...values: unknown[]) => unknown, + child: (...values: unknown[]) => unknown, +): Record { + return { + ...Object.fromEntries( + loggerMethods.slice(0, -1).map((level) => [level, method]), + ), + child, + }; +} + +/** + * A fresh logger of binden (branded): the logger of a context of no + * connection, a child of a logger that logs nothing + */ +function bindenLogger(): object { + const Request = binden.BindenRequest as new (socket: Socket) => object; + const Response = binden.BindenResponse as new (request: object) => object; + const request = new Request(new Socket()); + const quiet: Record = loggerOf(noop, () => quiet); + const made = Reflect.construct(binden.Context as IFunction, [ + { request, response: new Response(request), logger: quiet }, + ]) as object; + return Reflect.get(made, "log") as object; +} + +/** + * The valid values that the hostile values made of them stand for (a proxy, + * an object derived from one, or a context of a subclass of the same + * exchange, see `hostile()`): a reader finds the exchange of a hostile value + * by them, without running its traps (see `replied()`) + */ +const standsFor = new WeakMap(); + +/** A fresh hostile value (see `hostile()`) */ +function madeHostile(name: IHostileName, valid: unknown): IHostile { + const thrown: OwnError[] = []; + let conversions = 0; + const none = (): number => 0; + const trap = (): never => { + const error = new OwnError("A trap of the test"); + thrown.push(error); + throw error; + }; + /** A handler whose every trap throws */ + const every: ProxyHandler = { + apply: trap, + construct: trap, + defineProperty: trap, + deleteProperty: trap, + get: trap, + getOwnPropertyDescriptor: trap, + getPrototypeOf: trap, + has: trap, + isExtensible: trap, + ownKeys: trap, + preventExtensions: trap, + set: trap, + setPrototypeOf: trap, + }; + /** A proxy of the valid value (of an object, for a primitive) */ + const one = (handler: ProxyHandler): IHostile => ({ + name, + value: new Proxy(isObject(valid) ? valid : {}, handler), + conversions: none, + thrown, + }); + switch (name) { + case "symbol": + return { name, value: Symbol("hostile"), conversions: none, thrown }; + case "null-prototype": + return { name, value: Object.create(null), conversions: none, thrown }; + case "null": + return { name, value: null, conversions: none, thrown }; + case "undefined": + return { + name, + value: Reflect.get({}, "undefined"), + conversions: none, + thrown, + }; + case "bigint": + return { name, value: 10n, conversions: none, thrown }; + case "revoked": { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return { name, value: proxy, conversions: none, thrown }; + } + case "throwing": + return { name, value: new Proxy({}, every), conversions: none, thrown }; + case "revoked-function": { + const { proxy, revoke } = Proxy.revocable(noop, {}); + revoke(); + return { name, value: proxy, conversions: none, thrown }; + } + case "throwing-function": + return { + name, + value: new Proxy(noop, every), + conversions: none, + thrown, + }; + case "class": + return { + name, + value: class Hostile { + public readonly created = Date.now(); + }, + conversions: none, + thrown, + }; + case "prototype-trap": + return one({ getPrototypeOf: trap }); + case "keys-trap": + return one({ ownKeys: trap }); + case "has-trap": + return one({ has: trap }); + case "derived": + // An object whose prototype is the valid value (e.g. to override one + // of its methods): it has none of the private fields of the valid + // value, and a check of its prototype chain takes it for one + return { + name, + value: Object.create(isObject(valid) ? valid : {}) as object, + conversions: none, + thrown, + }; + case "transparent": + // A proxy of the valid value without any trap: it has none of the + // private fields of the valid value either + return { + name, + value: new Proxy(isObject(valid) ? valid : {}, {}), + conversions: none, + thrown, + }; + case "late-use": + case "late-write": { + // The valid value, used as it is (its methods bound to it, so that its + // private fields are read, and the objects that it gives used in the + // same way, e.g. the response of a context), until the call has + // returned: a later use (e.g. after an `await`) throws, or for + // `late-write` a later write only (e.g. the setter of `request.body` + // once a body is read), as it goes on reading + const writes = name === "late-write"; + let armed = false; + const proxies = new WeakMap(); + const handler: ProxyHandler = { + get(target: object, key: PropertyKey): unknown { + if (armed && !writes) { + return trap(); + } + const value: unknown = Reflect.get(target, key, target); + const own = Reflect.getOwnPropertyDescriptor(target, key); + if ( + typeof value !== "object" || + value === null || + (own?.configurable === false && own.writable === false) + ) { + // A function is bound to its object (a class is not called) + return typeof value === "function" && !isClass(value) + ? (value as IFunction).bind(target) + : value; + } + const proxy = proxies.get(value) ?? new Proxy(value, handler); + proxies.set(value, proxy); + return proxy; + }, + }; + const forwarded = (key: (typeof trapNames)[number]): IFunction => + function forward(...values: unknown[]): unknown { + if (armed && (!writes || lateWrites.has(key))) { + return trap(); + } + // A setter of the valid value sets its private fields + return key === "set" && writes + ? Reflect.set( + values[0] as object, + values[1] as PropertyKey, + values[2], + values[0], + ) + : Reflect.apply( + Reflect.get(Reflect, key) as IFunction, + Reflect, + values, + ); + }; + for (const key of trapNames) { + Reflect.set(handler, key, forwarded(key)); + } + return { + name, + value: new Proxy(valid as object, handler), + conversions: none, + thrown, + arm(): void { + armed = true; + }, + }; + } + case "late": + return { + name, + value: valid, + conversions: none, + thrown, + place(value: unknown): PropertyDescriptor { + let reads = 0; + return { + configurable: true, + enumerable: true, + get(): unknown { + reads += 1; + return reads > 1 ? trap() : value; + }, + }; + }, + }; + case "child-throws": + // A logger (at the position of one) whose `child()` gives a logger + // whose every method throws (e.g. a bug of the application) + return { + name, + value: loggerOf(noop, () => loggerOf(trap, trap)), + conversions: none, + thrown, + }; + case "child-other": + // A logger whose `child()` gives what is not a logger: an object + // whose every trap throws + return { + name, + value: loggerOf(noop, () => new Proxy({}, every)), + conversions: none, + thrown, + }; + case "replaced": + case "child-replaced": { + // A logger of binden whose own methods (and `child()`) the application + // has replaced, once it was made, by ones that throw (e.g. a wrapper + // with a bug, see R29-C1), given as it is, or as the child that a + // logger gives (e.g. as `context.log`): binden never runs them for a + // record of its own (see `misused`) + const logger = bindenLogger(); + let misuses = 0; + const misuse = (): never => { + misuses += 1; + return trap(); + }; + for (const method of loggerMethods) { + Object.defineProperty(logger, method, { + configurable: true, + enumerable: true, + writable: true, + value: misuse, + }); + } + return { + name, + value: name === "replaced" ? logger : loggerOf(noop, () => logger), + conversions: none, + thrown, + misused: () => misuses, + }; + } + case "derived-result": + case "proxy-result": + // A function (at the position of one) whose result is an object + // derived from its first argument, or a proxy of it, that has none of + // its private fields (e.g. a middleware that returns a context made + // from its own, which binden must not take for one) + return { + name, + value: (argument: unknown): unknown => { + if (!isObject(argument)) { + return argument; + } + return name === "derived-result" + ? (Object.create(argument) as object) + : new Proxy(argument, {}); + }, + conversions: none, + thrown, + }; + case "log-getter": + // The context itself (at the position of one), with a `log` getter of + // its own that throws (e.g. one that a middleware has defined) + Object.defineProperty(valid, "log", { + configurable: true, + enumerable: true, + get: trap, + }); + return { name, value: valid, conversions: none, thrown }; + case "log-subclass": { + // A context of the same request, of a subclass of `Context` whose + // `log` getter throws + const Subclass = class extends (binden.Context as new ( + options: object, + ) => object) { + // eslint-disable-next-line class-methods-use-this + public get log(): never { + return trap(); + } + }; + return { + name, + value: new Subclass({ + request: Reflect.get(valid as object, "request") as unknown, + response: Reflect.get(valid as object, "response") as unknown, + }), + conversions: none, + thrown, + }; + } + case "counting": + default: { + const value = { + toString(): string { + conversions += 1; + return "a"; + }, + valueOf(): number { + conversions += 1; + return 1; + }, + [Symbol.toPrimitive](): string { + conversions += 1; + return "a"; + }, + }; + return { name, value, conversions: () => conversions, thrown }; + } + } +} + +/** + * A fresh hostile value, made from the valid value of its position (e.g. the + * proxies of it whose one trap throws, and `late`) + */ +function hostile(name: IHostileName, valid: unknown): IHostile { + const made = madeHostile(name, valid); + if (isObject(made.value) && isObject(valid) && made.value !== valid) { + standsFor.set(made.value, valid); + } + return made; +} + +/** + * The hostile values of the probes of a receiver in another state than the + * default one (see `states`): one of each kind (a refusal of its type, a + * proxy that V8 refuses, an error of the application, a conversion, an + * absent value and a later use), so that a path that the state takes is + * probed, at a fraction of the cost of every value (every baseline is probed + * in every state), and at a position whose valid value is a function, the + * functions too (see `functionHostiles`) + */ +const stateHostiles: ReadonlySet = new Set([ + "symbol", + "undefined", + "revoked", + "throwing", + "counting", + "late-use", +]); +/** + * The hostile functions, also probed in the other states at a position whose + * valid value is a function (e.g. the `stringify` of `json()` once the + * response has ended): a revoked one, one whose traps throw, and a class + */ +const functionHostiles: ReadonlySet = new Set([ + "revoked-function", + "throwing-function", + "class", +]); +/** + * The hostile values that are made of an object or a function (of the valid + * value of their position): probed only where the valid value is one + */ +const objectHostiles: ReadonlySet = new Set([ + "derived", + "transparent", + "late-use", + "late-write", +]); +/** + * The hostile loggers: probed only where the valid value is a logger (e.g. + * the `logger` of `Binden`, see `isLoggerValue()`) + */ +const loggerHostiles: ReadonlySet = new Set([ + "child-throws", + "child-other", + "replaced", + "child-replaced", +]); +/** + * The hostile functions whose result is hostile: probed only where the + * valid value is a function (e.g. a middleware, whose result binden takes + * for a context only when it is one) + */ +const resultHostiles: ReadonlySet = new Set([ + "derived-result", + "proxy-result", +]); +/** + * The hostile contexts: probed only where the valid value is a context (e.g. + * of `Compression#run()`, see `isContextValue()`) + */ +const contextHostiles: ReadonlySet = new Set([ + "log-getter", + "log-subclass", +]); + +/** A logger that logs nothing (and so formats no error) */ +const silent = { + fatal: noop, + error: noop, + warn: noop, + info: noop, + debug: noop, + trace: noop, + child: (): unknown => silent, +}; + +/** + * The valid request of the last call of the listener of `callback()`, and + * the messages that its application has logged at `error` since + */ +const listened: { request: IncomingMessage | null; errors: unknown[] } = { + request: null, + errors: [], +}; +/** What the listener of `callback()` logs for a request of another kind */ +const otherKind = + "The request and the response are not those of the application (`serverOptions()`)"; +/** A logger that logs nothing, but records its messages at `error` */ +const recording = { + ...silent, + error(message: unknown): void { + listened.errors.push(message); + }, + child: (): unknown => recording, +}; + +/** + * The last record of the logger of the application of the exchanges (its + * extra fields), and the bindings of its last child + */ +const remembered: { extra: unknown; bindings: unknown } = { + extra: null, + bindings: null, +}; +/** The logger of the application of the exchanges (see `remembered`) */ +const remembering: Record = loggerOf( + (_: unknown, extra: unknown): void => { + remembered.extra = extra; + }, + (bindings: unknown): unknown => { + remembered.bindings = bindings; + return remembering; + }, +); + +/** + * The messages of the errors of the engine for a private member that binden + * has logged (see `watching`): binden has taken a value of the test for one + * of its objects (e.g. a context derived from one that a middleware returns) + */ +const privateFailures: string[] = []; +/** Whether a message is one of the engine for a private member of a class */ +const privateMember = + /^(?:Cannot (?:read|write) private member|Receiver must be an instance of class|Object must be an instance of class)/u; +/** Records the messages of the failures (and their causes) of a record */ +function watched(extra: unknown): void { + let failure: unknown = isObject(extra) ? Reflect.get(extra, "failure") : null; + for (let depth = 0; depth < 4 && isObject(failure); depth += 1) { + const message: unknown = Reflect.get(failure, "message"); + if (typeof message === "string" && privateMember.test(message)) { + privateFailures.push(message); + } + failure = Reflect.get(failure, "cause"); + } +} +/** + * The logger of the applications of the readers (with the details of their + * errors, see `privateFailures`): it logs nothing + */ +const watching: Record = loggerOf( + (_: unknown, extra: unknown): void => { + watched(extra); + }, + (): unknown => watching, +); + +/** A destination of `pino` that keeps nothing */ +const sink = { write: noop }; + +/** Waits for a turn of the event loop (late failures are reported then) */ +function turn(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + +/** Waits for a turn of the event loop, and `ms` more */ +async function delay(ms: number): Promise { + await turn(); + if (ms > 0) { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } +} + +/** Waits for a promise, for `ms` at most: an `Error` that says what expired */ +async function bounded( + promise: Promise | T, + ms: number, + what: string, +): Promise { + const { promise: expired, reject } = Promise.withResolvers(); + const timeout = setTimeout(() => { + reject(new Error(`${what} has not settled in ${String(ms)} ms`)); + }, ms); + try { + return await Promise.race([promise, expired]); + } finally { + clearTimeout(timeout); + } +} + +/** What a probe has: what to clean up once it is done */ +interface IEnv { + /** + * A fresh request, response and context of a connection of their own (a + * `GET`, or a `POST` of a body, see `IBody`) + */ + exchange(body?: IBody): Promise; + /** A fresh HTTP server, not listening */ + server(): Server; + /** + * A fresh HTTP server, not listening, that the environment does not hold: + * its cleanup closes it through a weak reference (a listening server is + * held by its handle), so that what a call keeps of it is told (see + * `retainedBy()`) + */ + ownServer(): Server; + /** A fresh HTTP server, listening */ + listening(): Promise; + readonly cleanups: (() => unknown)[]; +} + +/** + * The body of the request of an exchange: none (a `GET`), the body of a + * `POST` over the limits of the parsers of the fixtures (`text`, chunked, of + * 10 bytes, and `multipart`, a file of 8 bytes), or within them, which they + * parse (`small-text`, chunked, of 2 bytes, `small-json`, of 3 bytes, and + * `small-multipart`, a file of 1 byte) + */ +type IBody = + | "multipart" + | "none" + | "small-json" + | "small-multipart" + | "small-text" + | "text"; + +/** The head (and the body) of the request of an exchange */ +function requestOf(body: IBody, id: string, at: number): string { + const form = (content: string): string => + [ + "--B", + 'Content-Disposition: form-data; name="f"; filename="a.txt"', + "Content-Type: text/plain", + "", + content, + "--B--", + "", + ].join("\r\n"); + const multipart = (content: string): readonly [string, string[], string] => [ + "POST", + [ + "Content-Type: multipart/form-data; boundary=B", + `Content-Length: ${String(form(content).length)}`, + ], + form(content), + ]; + const requests: Readonly< + Record + > = { + none: ["GET", ["Content-Encoding: gzip", "Content-Type: text/plain"], ""], + text: [ + "POST", + ["Content-Type: text/plain", "Transfer-Encoding: chunked"], + "2\r\nab\r\n8\r\nabcdefgh\r\n0\r\n\r\n", + ], + "small-text": [ + "POST", + ["Content-Type: text/plain", "Transfer-Encoding: chunked"], + "2\r\nab\r\n0\r\n\r\n", + ], + "small-json": [ + "POST", + ["Content-Type: application/json", "Content-Length: 3"], + "[1]", + ], + multipart: multipart("abcdefgh"), + "small-multipart": multipart("a"), + }; + const [method, fields, content] = requests[body]; + return [ + `${method} /path?a=1 HTTP/1.1`, + `Host: ${HOST}:${String(at)}`, + "Accept: text/html", + "Accept-Charset: utf-8", + "Accept-Encoding: gzip", + "Accept-Language: en", + "Authorization: Basic YTpi", + ...fields, + "Cookie: a=b", + "Forwarded: for=192.0.2.1", + "If-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT", + "Range: bytes=0-1", + `X-Probe: ${id}`, + "", + content, + ].join("\r\n"); +} + +/** A request being handled by the application of the fixture */ +interface IExchange { + readonly context: object; + readonly request: object; + readonly response: object; +} + +const Binden = binden.Binden as new (options: object) => { + createServer(): Server; + use(fn: IFunction): unknown; +}; +/** + * The middleware of the exchanges waiting for their probe, by their id: it + * is given what releases the work of the exchange, and what fails it + */ +const waiting = new Map< + string, + ( + exchange: IExchange, + release: () => void, + fail: (error: unknown) => void, + ) => void +>(); +/** + * What fails the work of the middleware of an exchange with an error, by its + * response, so that binden replies with its default error reply: the status + * line and the head that its client gets (`""` for none, once its connection + * has closed) + */ +const failingWork = new WeakMap Promise>(); +/** + * The responses of the exchanges, by their contexts, requests and responses + * (see `replied()`) + */ +const exchangeResponses = new WeakMap(); +/** + * What runs a function as the work of the middleware of an exchange (e.g. + * a pipe of that work, see `IOwnership` of binden), by its response + */ +const works = new WeakMap(fn: () => T) => T>(); +const app = new Binden({ logger: remembering }); +app.use((context: unknown) => { + const { request, response } = context as { + request: IncomingMessage; + response: object; + }; + works.set(response, AsyncLocalStorage.snapshot()); + const id = String(request.headers["x-probe"]); + const { promise, resolve, reject } = Promise.withResolvers(); + waiting.get(id)?.( + { context: context as object, request, response }, + () => { + resolve(null); + }, + reject, + ); + return promise; +}); +const server = app.createServer(); +server.listen(0, HOST); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +let exchanges = 0; + +/** Describes a thrown value without running code of the application */ +function describe(error: unknown): string { + if (isProxy(error) || typeof error !== "object" || error === null) { + return `(${typeof error})`; + } + if (isNativeError(error)) { + // On one line (e.g. a message of Node.js that quotes an object) + return `${error.constructor.name}: ${error.message.replaceAll(/\s*\n\s*/gu, " ")}`; + } + return "(an object)"; +} + +/** + * Closes the client side of a connection once the server has closed it (its + * `close`, bounded): the server closes first, so that the ports of the + * clients do not wait in `TIME_WAIT` (a run makes thousands of connections, + * and back-to-back runs would exhaust them) + */ +async function closed(socket: Socket): Promise { + if (!socket.closed) { + const { promise: expired, resolve } = Promise.withResolvers(); + const timeout = setTimeout(() => { + resolve(null); + }, 1000); + await Promise.race([once(socket, "close"), expired]); + clearTimeout(timeout); + } + socket.destroy(); +} + +/** A fresh environment of a probe */ +function environment(): IEnv { + const cleanups: (() => unknown)[] = []; + return { + cleanups, + exchange(body: IBody = "none"): Promise { + exchanges += 1; + const id = String(exchanges); + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = new Socket(); + let arrived = false; + // Before its request has arrived, a failure of its socket fails the + // exchange (and so the probe), instead of leaving it pending + const failed = (why: string): void => { + if (!arrived) { + waiting.delete(id); + reject(new Error(`The connection of an exchange ${why}`)); + } + }; + socket.on("error", (error) => { + failed(`has failed (${describe(error)})`); + }); + // What its client gets: the head of the reply (see `failingWork`) + let received = ""; + const { promise: headOf, resolve: answered } = + Promise.withResolvers(); + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + const end = received.indexOf("\r\n\r\n"); + if (end !== -1) { + answered(received.slice(0, end)); + } + }); + socket.on("close", () => { + answered(""); + failed("has closed before its request"); + }); + cleanups.push(() => closed(socket)); + waiting.set(id, (exchange, release, fail) => { + arrived = true; + waiting.delete(id); + failingWork.set(exchange.response, (error) => { + fail(error); + return headOf; + }); + for (const value of [ + exchange.context, + exchange.request, + exchange.response, + ]) { + exchangeResponses.set(value, exchange.response); + } + const response = exchange.response as EventEmitter & { + readonly closed: boolean; + destroy(): void; + }; + // The socket of the server (also once a response that has ended is + // detached from it) + const { socket: own } = exchange.request as IncomingMessage; + // Run before the cleanup of the socket: the server closes first + cleanups.push(async () => { + const ended = response.closed ? null : once(response, "close"); + release(); + own.destroy(); + try { + response.destroy(); + } catch { + // E.g. once a probe has given it hostile handlers + } + await ended; + }); + resolve(exchange); + }); + socket.resume(); + socket.connect(port, HOST); + socket.write(requestOf(body, id, port)); + return promise; + }, + server(): Server { + const created = createHttpServer(); + cleanups.push(() => created.close()); + return created; + }, + ownServer(): Server { + const created = createHttpServer(); + const ref = new WeakRef(created); + cleanups.push(() => ref.deref()?.close()); + return created; + }, + async listening(): Promise { + const created = createHttpServer(); + cleanups.push(() => { + created.closeAllConnections(); + created.close(); + }); + created.listen(0, HOST); + await once(created, "listening"); + return created; + }, + }; +} + +/** + * Handles one request (`GET /path` of `example.com` by default, its + * connection closed once it is replied) by an application, on a server of + * its own (closed then): the status line of its reply (e.g. its access log, + * and the logging of its contexts, use its `logger`) + */ +async function handled( + application: unknown, + method = "GET", + path = "/path", +): Promise { + const created = Reflect.apply( + Reflect.get(application as object, "createServer") as IFunction, + application, + [], + ) as Server; + try { + created.listen(0, HOST); + await bounded(once(created, "listening"), ioMs, "Its server"); + const { port: at } = created.address() as AddressInfo; + const socket = new Socket(); + let reply = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + reply += chunk; + }); + // Its error closes it too + socket.on("error", noop); + const ended = once(socket, "close"); + socket.connect(at, HOST); + socket.write( + `${method} ${path} HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n`, + ); + try { + await bounded(ended, ioMs, "A request of the application"); + } finally { + socket.destroy(); + } + return reply.split("\r\n")[0] ?? ""; + } finally { + created.closeAllConnections(); + created.close(); + } +} + +/** + * How many times binden has run a method that a middleware has replaced on + * `context.log` (see `routed()`), for a record of its own + */ +const wrapped = { misuses: 0 }; +/** + * A middleware that replaces the methods of `context.log` (as one that adds + * a field to its records) by ones that throw (e.g. a bug of that wrapper, + * R29-C1), which binden never runs for its own records (e.g. its access log) + */ +function wrapping(context: unknown): void { + const log = Reflect.get(context as object, "log") as object; + for (const method of loggerMethods) { + Object.defineProperty(log, method, { + configurable: true, + enumerable: true, + writable: true, + value(): never { + wrapped.misuses += 1; + throw new OwnError("A wrapper of the test"); + }, + }); + } +} + +/** An application of the readers (see `routed()`) */ +function wrappedApplication(): { + use(...items: unknown[]): unknown; + mount(path: string, ...items: unknown[]): unknown; +} { + const application = new Binden({ + logger: watching, + log_error_details: true, + access_log: true, + }); + application.use(wrapping); + return application as unknown as ReturnType; +} + +/** + * Handles one request (of a method, `GET` by default) by an application of a + * router (see `handled()`), after a middleware that replaces the methods of + * `context.log` (see `wrapping()`), and with an access log, which logs to + * `watching` (with the details of its errors): e.g. whether binden takes the + * result of a middleware for a context + */ +function routed(router: unknown, method = "GET"): Promise { + const application = wrappedApplication(); + application.use(router); + return handled(application, method); +} + +/** + * Handles one request of a path by an application that has mounted a stack + * (see `routed()`), whose items run for it + */ +function stacked(stack: unknown, path: string): Promise { + const application = wrappedApplication(); + application.mount("/", stack); + return handled(application, "GET", path); +} + +/** + * Gives the listener of `callback()` of an application a request and a + * response of another kind (plain ones of Node.js): it logs them through the + * logger of the application (see `otherKind`), by its own path, and closes + * the connection + */ +function foreignRequest(application: unknown): unknown { + const created = createHttpServer(); + try { + const listener = Reflect.apply( + Reflect.get(application as object, "callback") as IFunction, + application, + [created], + ) as IFunction; + const request = new IncomingMessage(new Socket()); + return listener(request, new ServerResponse(request)); + } finally { + created.close(); + } +} + +/** + * Registers a cleanup that fails on a context whose `log` a middleware has + * wrapped (see `wrapping()`), then closes its response (by its `close`, for + * a response of no connection): the report of its request (of the context + * that has made it, e.g. by default one that logs by the own path of binden) + * is given the failure + */ +function failedCleanup(context: unknown): void { + wrapping(context); + Reflect.apply( + Reflect.get(context as object, "onCleanup") as IFunction, + context, + [ + (): never => { + throw new Error("A cleanup"); + }, + ], + ); + const response = Reflect.get(context as object, "response") as ServerResponse; + response.destroy(); + if (response.socket === null) { + response.emit("close"); + } +} + +/** + * A reply of a context whose `log` a middleware has wrapped (see + * `wrapping()`) once its deadline has passed: binden ignores it, and logs + * that by its own path + */ +async function lateReply(context: unknown): Promise { + wrapping(context); + const target = context as { + readonly signal: AbortSignal; + setTimeout(ms: number): void; + send(body: string): Promise; + }; + target.setTimeout(1); + if (!target.signal.aborted) { + await once(target.signal, "abort"); + } + return target.send("late"); +} + +/** + * Runs a function on a fresh exchange (see `IEnv.exchange()`) of a body, + * then cleans it up: what the function gives + */ +async function exchanged( + body: IBody, + fn: (exchange: IExchange) => Promise, +): Promise { + const env = environment(); + try { + return await fn(await env.exchange(body)); + } finally { + for (const cleanup of env.cleanups.reverse()) { + await cleanup(); + } + } +} + +/** + * Runs a parser (its `run()`) on the context of a fresh exchange of a body + * that it parses: the type of what the body has become (`request.body`), or + * what `run()` rejects with + */ +function parsedBy(parser: unknown, body: IBody): Promise { + return exchanged(body, async ({ context, request }) => { + await Reflect.apply( + Reflect.get(parser as object, "run") as IFunction, + parser, + [context], + ); + return typeof Reflect.get(request, "body"); + }); +} + +/** + * Runs a compression (its `run()`) on the context of a fresh exchange (whose + * request accepts `gzip`, or the coding given), and sends a body: the status + * of the response (a filter that fails replaces it by a `500`) + */ +function compressedBy( + compression: unknown, + coding: string | null = null, +): Promise { + return exchanged("none", async ({ context, request, response }) => { + if (coding !== null) { + (request as IncomingMessage).headers["accept-encoding"] = coding; + } + await Reflect.apply( + Reflect.get(compression as object, "run") as IFunction, + compression, + [context], + ); + await (response as { send(body: string): Promise }).send("text"); + return (response as ServerResponse).statusCode; + }); +} + +/** + * Ends the response of a context, and waits until it is closed: the + * cleanups of its request have run then + */ +async function endedResponse(context: unknown): Promise { + const response = Reflect.get(context as object, "response") as ServerResponse; + if (!response.closed) { + const ended = once(response, "close"); + response.end(); + await ended; + await turn(); + } +} + +/** + * Destroys a response, and waits until it is closed (its observers have run + * then, see `[kListen]()`), before any of them + */ +async function closedBy(response: unknown): Promise { + const target = response as ServerResponse; + if (!target.closed) { + const { promise, resolve } = Promise.withResolvers(); + target.prependOnceListener("close", () => { + resolve(null); + }); + target.destroy(); + await promise; + } +} + +/** + * A reader that emits an event on its receiver (an emitter) at the next turn + * of the event loop: its listeners run then, outside of any call (see + * `callbacks`) + */ +function laterEvent(event: string): IReader { + return (receiver) => { + setImmediate(() => { + (receiver as EventEmitter).emit(event); + }); + }; +} + +/** + * The states of a response (also of a context) that its members are probed + * in, besides a fresh one: its head sent, ended, destroyed, and two fresh + * ones that the application has changed: its `statusCode` is an object + * (`coerced`, see `statusObject()`), and its `write()` is replaced by one that + * ends it with the chunk, then detaches the bytes of a view of the test + * (`detaching`, see `viewOf()`), as a writer of a library (e.g. the one of + * @binden/compression) may hand them on + */ +const states = ["sent", "ended", "destroyed", "coerced", "detaching"] as const; +/** The state of a response */ +type IState = "fresh" | (typeof states)[number]; +/** + * The states whose receivers are probed by the baselines only: what they + * check is the call itself (how often it converts the status, and whether a + * send whose bytes are lost rejects, see `loses`) + */ +const baselineStates: ReadonlySet = new Set(["coerced", "detaching"]); + +/** + * An object that converts to the status 200 (by any hint), and how many times + * it has been converted + */ +function statusObject(): readonly [object, () => number] { + let conversions = 0; + return [ + { + [Symbol.toPrimitive](): number { + conversions += 1; + return 200; + }, + }, + (): number => conversions, + ]; +} + +/** + * The memory of the views of the test, which the writer of `detaching` + * detaches (also under a view of binden that shares it, e.g. the `Buffer` of + * a send of a view) + */ +const detachable = new WeakSet(); +/** A view of the test (`text`), whose bytes the writer of `detaching` detaches */ +function viewOf(): Uint8Array { + const view = new Uint8Array([116, 101, 120, 116]); + detachable.add(view.buffer); + return view; +} + +/** + * Puts a fresh response in a state: how many times its `statusCode` has been + * converted since, for `coerced` + */ +function enter(response: unknown, state: IState): (() => number) | null { + const target = response as ServerResponse; + if (state === "sent") { + target.writeHead(200); + target.flushHeaders(); + } else if (state === "ended") { + target.end(); + } else if (state === "destroyed") { + target.destroy(); + } else if (state === "coerced") { + const [status, conversions] = statusObject(); + target.statusCode = status as unknown as number; + return conversions; + } else if (state === "detaching") { + Object.defineProperty(target, "write", { + configurable: true, + writable: true, + value(chunk: unknown, ...rest: unknown[]): boolean { + if (!target.writableEnded) { + target.end( + chunk as string, + (typeof rest[0] === "string" ? rest[0] : "utf8") as BufferEncoding, + ); + } + const buffer = ArrayBuffer.isView(chunk) ? chunk.buffer : null; + if (buffer !== null && detachable.has(buffer)) { + structuredClone(buffer, { transfer: [buffer as ArrayBuffer] }); + } + const callback = rest.find((value) => typeof value === "function"); + if (typeof callback === "function") { + Reflect.apply(callback, null, []); + } + return true; + }, + }); + } + return null; +} + +/** + * The conversions of the `statusCode` of the receivers in the `coerced` + * state, by the receivers + */ +const statusConversions = new WeakMap number>(); + +/** A plain response of Node.js (of no connection), in a state */ +function plainResponse(state: IState): ServerResponse { + const response = new ServerResponse(new IncomingMessage(new Socket())); + enter(response, state); + return response; +} + +/** A receiver of the members of a class (or of what a package installs) */ +interface IReceiver { + /** What it is, `""` for the default one (e.g. `sent` or `instance 1`) */ + readonly name: string; + /** The state of the response that it is, or has */ + readonly state: IState; + make(env: IEnv): unknown; +} + +/** The default receiver, made by a function */ +function fresh(make: (env: IEnv) => unknown): IReceiver { + return { name: "", state: "fresh", make }; +} + +/** + * The receivers of the members of a response (or of an object that has one, + * whose response `of()` gives): a fresh one, and one in each state (but + * `detaching` for a writer that a package installs, which it would replace) + */ +function stated( + make: (env: IEnv) => unknown, + of: (made: unknown) => unknown, + detaching = true, +): readonly IReceiver[] { + return [ + fresh(make), + ...states + .filter((state) => detaching || state !== "detaching") + .map((state): IReceiver => ({ + name: state, + state, + async make(env: IEnv): Promise { + const made = await make(env); + const conversions = enter(of(made), state); + if (conversions !== null) { + statusConversions.set(made as object, conversions); + } + await turn(); + return made; + }, + })), + ]; +} + +/** The arguments of a valid call, made fresh for each probe */ +type ICall = ( + env: IEnv, + receiver: unknown, +) => Promise | readonly unknown[]; + +/** What the plain call of Node.js gets (see `node`) */ +interface IPlain { + readonly env: IEnv; + /** A plain response of Node.js, in the state of the receiver of the probe */ + response(): ServerResponse; +} + +/** + * The same call on plain Node.js (e.g. of `setHeader()` on a plain response), + * with the same values + */ +type INode = (values: readonly unknown[], plain: IPlain) => unknown; + +/** What a fixture adds to its calls */ +interface IFixtureOptions { + /** + * Nested positions of the arguments probed as well (e.g. `[0, 0]`, the + * first element of an array given first, or `[0, "key"]`): of every call + * that has them + */ + readonly deep?: readonly (readonly (number | string)[])[] | undefined; + /** + * The positions (e.g. `"0"` or `"0.key"`) whose conversion is documented + * (see `headerInit` and `nodeCoercion`) + */ + readonly converts?: Readonly> | undefined; + /** + * The same call on plain Node.js: a position of `nodeCoercion` is + * converted as many times by the call as by it + */ + readonly node?: INode | undefined; + /** + * The fixtures of the functions and accessors that its first valid call + * installs as own properties of its receiver, of an argument, or of the + * request or the response of a `Context` given (e.g. `0.response.write` of + * `Compression#run()`): probed as entry points of their own + */ + readonly installed?: Readonly> | undefined; + /** + * The fixture of the function that its first valid call returns (e.g. the + * listener of `callback()`): probed as an entry point of its own + */ + readonly returned?: IFixture | undefined; + /** + * The fixtures of the members (methods, classes and accessors) of the + * object that its first valid call returns, by their names (e.g. `add()` + * and `active`): probed as entry points of their own (an accessor without + * one is read) + */ + readonly members?: Readonly> | undefined; + /** + * The readers of what a valid call stores (e.g. `[kRepresentation]()` of + * the negotiator that `[kNegotiator]()` stores), by their names: called on + * its receiver (and given what the call has returned, e.g. what a + * constructor has made, and the arguments of the call) once a call has + * succeeded. A position whose value the call keeps, for a later use, is + * bound to a reader that uses it (see `IUntouched`) + */ + readonly readers?: Readonly> | undefined; + /** + * The readers that log as the application does (e.g. a call of + * `context.log.error()`): a method that the application has replaced on a + * logger of binden (see `misused`) is its own to run there. Each must run + * one (an allowance that nothing uses is a finding). + */ + readonly logs?: readonly string[] | undefined; + /** + * The readers that run a function of the test (e.g. an option that a + * parser calls) whose failure they document as a `BindenError` of it (its + * `cause`, e.g. the `500` of @binden/body), never as itself: one that + * throws or rejects with the error of the test is a finding, and one with + * a formatted `BindenError` whose cause it is passes it on + */ + readonly wraps?: readonly string[] | undefined; + /** + * What never fails, as documented (e.g. the logger of a context): its + * calls (`call`), or its readers (by their names), each with where it is + * documented. One that throws or rejects (even what a value of the test + * throws) is a finding. + */ + readonly infallible?: Readonly> | undefined; + /** + * Whether a call reads the `log` of the context that it is given in a + * guard (see `contextHostiles`), as documented (e.g. the parsers of + * @binden/body and @binden/multipart): what that getter throws drops the + * records of the call only, so that the call settles as it does without + * them, never with the error of the test (one that does is a finding, and + * so is a fixture that sets it, but whose calls no getter has failed) + */ + readonly dropsLog?: boolean | undefined; + /** + * The indexes of its calls that send a view of the test (see `viewOf()`): + * in the `detaching` state, the writer of the application loses its bytes, + * and the call must reject (and cut the response) + */ + readonly loses?: readonly number[] | undefined; + /** + * Whether its baselines throw by design (e.g. `context.throw()`), or the + * indexes of those that do: a formatted error + */ + readonly throws?: boolean | readonly number[] | undefined; + /** + * Whether it does I/O (e.g. reads a file, or codes a body): its calls may + * stay pending for `ioMs` + */ + readonly io?: boolean | undefined; + /** + * The positions that the call examines no hostile value at (each is + * accepted, as it is), and why (see `IUntouched`): one whose valid value + * is a function or an object names the reader that uses it later (e.g. a + * check that a mutation calls), or an alternative check + */ + readonly untouched?: + Readonly> | undefined; + /** + * The positions that the call examines (it refuses some hostile values + * there), but whose valid value, a function or an object, it keeps while + * it accepts others (see `retainedBy()`), and what uses it then (see + * `IKept`): e.g. an option that a parser calls as it parses a body. A + * position listed that no call keeps is a finding. + */ + readonly kept?: Readonly> | undefined; + /** + * The positions where the call accepts a hostile value, but whose valid + * value is not the application's own fresh value, so that what the call + * keeps of it is not told (see `retainedBy()`), each named, with why, and + * the reader that uses it later or an alternative check (see `IUntouched`), + * which must run: one that the environment of the call holds (e.g. a + * context of an exchange, which its server keeps), or that every valid call + * shares without a stand-in (see `standIn()`). A position listed whose + * retention is told is a finding. + */ + readonly held?: Readonly> | undefined; + /** + * The alternative checks of its positions that no reader can use (see + * `IUntouched.instead`), by their names: each runs once the entry point is + * probed (in the shard that probes it), and what it returns are findings + */ + readonly alternatives?: + Readonly Promise | string[]>> | undefined; + /** + * The positions of a callback that is called later, and why a function of + * the test that throws there is uncaught (as with Node.js) + */ + readonly callbacks?: Readonly> | undefined; + /** + * Whether its receiver (a response, see `installed`) is probed in the + * states of a response too (see `states`) + */ + readonly states?: boolean | undefined; + /** + * Whether a call has refused its arguments without throwing, as it + * documents (e.g. the listener of `callback()` logs a request or a + * response of another kind, and closes its connection): a probe whose + * call does examines its hostile value, and a baseline must not + */ + readonly refused?: ((values: readonly unknown[]) => boolean) | undefined; + /** + * The positions whose every hostile value the call must refuse that way + * (see `refused`), as documented, never by throwing (e.g. a response of + * another kind given to the listener of `callback()` with a request of the + * application, whose connection it closes) + */ + readonly refuses?: readonly string[] | undefined; +} + +/** + * Why the call examines no hostile value at a position whose valid value is + * a function or an object, and what uses it then: a reader (`later`, of + * `readers` or of the entry point), which must run with a hostile value + * there and use it (one of its traps or conversions runs as it reads, or it + * has another outcome than with the valid value), or, where no reader can, + * an alternative check (`instead`, of `alternatives`), which must exist and + * run: `callbacks` for a callback that Node.js calls later, which must be + * listed in `callbacks` (that checks that a function given there is called), + * `unread` for a value that is kept (e.g. by a closure of the call), but + * never read once the call has returned (the traps of `late-use` never run + * there, as its readers run), and `settled` for one that a call that does + * I/O reads until it settles, but never once it has settled (no trap of + * `late-use` runs as its readers run, e.g. as the exchange of a context + * replies, see `replied()`) + */ +type IUntouched = + | { readonly why: string; readonly later: string } + | { readonly why: string; readonly instead: string }; + +/** + * Why a call keeps the valid value (a function or an object) of a position + * that it examines, and what uses it then (see `kept`), as for `IUntouched`: + * at a position of a function used `later`, its reader must also tell each + * hostile function whose result is hostile (see `resultHostiles`) that the + * call accepts there from the valid one (a hostile result is checked), or + * `ignores` names it, with why (e.g. its result is ignored) + */ +type IKept = IUntouched & { + readonly ignores?: + Readonly>> | undefined; +}; + +/** The fixture of an entry point */ +interface IFixture extends IFixtureOptions { + /** Its valid calls (overloads): each is asserted to succeed first */ + readonly calls: readonly ICall[]; +} + +/** + * A documented conversion (README, Lifecycle): a value of the init of a + * header (and the name and the attributes of `clearCookie()`), converted once + * by `String()` + */ +const headerInit = "String() of a value of the init of a header"; +/** + * A documented conversion of Node.js, whose methods binden overrides (README, + * Lifecycle): e.g. of the name and the value of a header, of the status code + * of `writeHead()`, or of the name of an event, as many times as plain + * Node.js does it (see `node`) + */ +const nodeCoercion = "a coercion of Node.js, through an override"; +/** + * A documented conversion of the constructor of `File` of Node.js, which + * `MultipartFile` extends (README of @binden/multipart, Lifecycle): of its + * name, `type` and `lastModified`, as many times as a plain `File` does it + * (see `node`) + */ +const fileCoercion = "a coercion of the constructor of File of Node.js"; + +/** + * A documented conversion of a check of binden of the arguments of + * `writeHead()` (`[kValidateHead]()`, internal), which checks them as Node.js + * does: at most as many times as plain Node.js converts them in that call + * (see `node`) + */ +const nodeCheck = "a check of the arguments of writeHead(), as Node.js does"; + +/** + * A documented conversion of a cookie that `response.cookies.add()` keeps + * (README, BindenResponse): its `toString()`, which gives the value of its + * `Set-Cookie`, read once as the head is committed, by a reader (see + * `committed()`), never by the call + */ +const cookieValue = + "the toString() of a cookie, read once as the head is committed"; + +/** Why a field of Node.js that binden guards examines no value */ +const nodeField = + "a field of Node.js, stored as it is, as on a plain response (Node.js reads it as it writes the head)"; +/** + * Why a callback position examines no value: a function given there is + * called later, as `callbacks` checks + */ +const ignoredCallback: IUntouched = { + why: "a callback that is not a function is ignored, as by Node.js", + instead: "callbacks", +}; + +/** A function given as a middleware */ +const middleware = (): void => { + // Nothing +}; +/** + * Why the hostile functions whose result is hostile (see `resultHostiles`) + * are not told from the valid one at a position whose result is ignored + */ +function ignoredBy( + option: string, + where: string, +): Partial> { + const why = `the result of \`${option}\` is ignored (${where})`; + return { "derived-result": why, "proxy-result": why }; +} +/** Why a hostile function whose result is hostile is ignored as a middleware */ +const ignoredResult = + "a result that is not a context of the request is ignored, as the one of the valid middleware, which returns none (see `privateFailures`)"; +/** + * Why a stack keeps a middleware, and the reader that runs it (for a request + * that it matches) + */ +function keptMiddleware(later: string): IKept { + return { + why: "a middleware, kept, and run for a request that it matches", + later, + ignores: { "derived-result": ignoredResult, "proxy-result": ignoredResult }, + }; +} +/** A function that returns `1` */ +const one = (): number => 1; + +/** The fixtures of the entry points, by their ids */ +const fixtures: Record = {}; + +/** The receivers of the prototype members of the classes, by class id */ +const receivers: Record = { + "binden:BindenRequest": [ + fresh(async (env) => (await env.exchange()).request), + ], + "binden:BindenResponse": stated( + async (env) => (await env.exchange()).response, + (made) => made, + ), + "binden:Context": stated( + async (env) => (await env.exchange()).context, + (made) => Reflect.get(made as object, "response"), + ), +}; + +/** + * The exports that are instances of an exported class (and not probed: they + * are not entry points of their own), and why + */ +const instances: Readonly> = { + "@binden/logger:default": + "the default logger, a Logger whose members are probed on instances of their own", + "@binden/body:default": + "the default parser, a BodyParser whose members are probed on instances of their own", + "@binden/compression:default": + "the default compression, a Compression whose members are probed on instances of their own", +}; + +/** Adds the fixtures of the members of a class or of an object of a package */ +function define(prefix: string, defined: Record): void { + for (const [key, fixture] of Object.entries(defined)) { + fixtures[`${prefix}${key}`] = fixture; + } +} + +/** A fixture of its valid calls */ +function call(...calls: ICall[]): IFixture { + return { calls }; +} + +/** A fixture of its valid calls, and of what it adds to them */ +function probed(options: IFixtureOptions, ...calls: ICall[]): IFixture { + return { ...options, calls }; +} + +/** The nested paths of the keys of an object given at a position */ +function keysAt( + position: number, + object: object, + nested: readonly string[] = [], +): (readonly (number | string)[])[] { + return [ + ...Object.keys(object).map((key) => [position, key]), + ...nested.map((path) => [position, ...path.split(".")]), + ]; +} + +/** The same call of a method on a plain response of Node.js (see `node`) */ +function plainly(name: string): INode { + return (values, plain) => { + const response = plain.response(); + return Reflect.apply( + Reflect.get(response, name) as IFunction, + response, + values, + ); + }; +} + +/** Every option of `Binden` that its requests share with `validateRequestOptions()` */ +function requestOptions(): Record { + return { + trust_proxy: false, + encoded_slashes: false, + allowed_hosts: ["example.com"], + request_id: { header: "x-request-id", trust_incoming: false, echo: true }, + query: { max_keys: 10 }, + }; +} +/** Their nested options */ +const requestNested = [ + "allowed_hosts.0", + "request_id.header", + "request_id.trust_incoming", + "request_id.echo", + "query.max_keys", +]; + +/** Every option of `Binden` */ +function bindenOptions(): Record { + return { + auto_head: true, + error_handler: noop, + error_handler_timeout: 1000, + timeout: 1000, + logger: remembering, + log_error_details: false, + routing: { case_sensitive: false, strict: false }, + access_log: { level: "info", path: "path" }, + etag: true, + state: (): object => ({}), + ...requestOptions(), + }; +} + +/** + * The values of the init of a header that are converted once by `String()`, + * as README lists them (Headers), but the values of the parameters + */ +const stringInits: Readonly>> = { + Accept: ["type"], + AcceptCharset: ["charset"], + AcceptLanguage: ["language"], + Authorization: ["credentials"], + ContentType: ["type"], + Cookie: ["key", "value", "domain", "path"], +}; + +/** + * The `listen()` that the registration of a server for `shutdown()` installs + * on it (on a free port of the host), whose host and backlog Node.js converts + * (as on a plain server, which `server()` makes) + */ +function listen(make: (env: IEnv) => Promise | Server): IFixture { + /** It returns its server, whose `listen()` is itself */ + const returns: Record = {}; + const fixture = probed( + { + converts: { 1: nodeCoercion, 2: nodeCoercion }, + async node(values, plain): Promise { + const created = await make(plain.env); + // E.g. of a host that it does not find + created.on("error", noop); + return Reflect.apply( + Reflect.get(created, "listen") as IFunction, + created, + values, + ); + }, + members: returns, + }, + () => [0, HOST], + () => [0, HOST, noop], + ); + returns["listen()"] = fixture; + return fixture; +} + +// The headers +const headerDate = "Thu, 01 Jan 1970 00:00:00 GMT"; +for (const [name, inits, input, inputs] of [ + [ + "Accept", + [{ type: "text/html", parameters: { level: "1" }, q_value: 0.5 }], + "text/html", + true, + ], + ["AcceptCharset", [{ charset: "utf-8", q_value: 0.5 }], "utf-8", true], + ["AcceptEncoding", [{ encoding: "gzip", q_value: 0.5 }], "gzip", true], + ["AcceptLanguage", [{ language: "en", q_value: 0.5 }], "en", true], + [ + "Authorization", + [{ type: "Basic", credentials: "YTpi" }], + "Basic YTpi", + false, + ], + ["ContentEncoding", [{ encoding: "gzip" }], "gzip", false], + ["ContentRange", [{ start: 0, end: 1, size: 2 }], "bytes 0-1/2", false], + [ + "ContentType", + [ + { + type: "multipart/form-data", + charset: "utf-8", + boundary: "boundary", + parameters: { a: "b" }, + }, + // A charset of a type that has no boundary + { type: "text/plain", charset: "utf-8", parameters: { a: "b" } }, + ], + "text/plain; charset=utf-8", + false, + ], + [ + "Cookie", + [ + { + key: "a", + value: "b", + expires: new Date(0), + max_age: 1, + domain: "example.com", + path: "/", + secure: true, + http_only: true, + same_site: "None", + partitioned: true, + }, + ], + "a=b", + false, + ], + [ + "Forwarded", + [ + { + for: "192.0.2.1", + by: "192.0.2.2", + host: "example.com", + proto: "https", + secret: "secret", + }, + ], + "for=192.0.2.1", + true, + ], + ["IfModifiedSince", [{ date: new Date(0) }], headerDate, false], + ["Range", [{ start: 0, end: 1 }], "bytes=0-1", false], +] as const) { + /** The keys of the inits, and of their parameters (e.g. `parameters.level`) */ + const keys = new Set(); + for (const init of inits) { + for (const key of Object.keys(init)) { + keys.add(key); + } + if ("parameters" in init) { + for (const key of Object.keys(init.parameters)) { + keys.add(`parameters.${key}`); + } + } + } + const parameters = [...keys].filter((key) => key.startsWith("parameters.")); + define(`binden:`, { + [`new ${name}()`]: probed( + { + deep: [...keys].map((key) => [0, ...key.split(".")]), + // The values that README lists (Headers), and those of the parameters + converts: Object.fromEntries( + [...(stringInits[name] ?? []), ...parameters].map((key) => [ + `0.${key}`, + headerInit, + ]), + ), + untouched: { + ...(name === "Cookie" + ? Object.fromEntries( + ["secure", "http_only", "partitioned"].map((key) => [ + `0.${key}`, + "read by Boolean() (README, Lifecycle), which runs no code of the application", + ]), + ) + : {}), + }, + }, + ...inits.map((init) => (): readonly unknown[] => [structuredClone(init)]), + ), + [`${name}.fromString()`]: inputs + ? probed( + { deep: [[0, 0]] }, + () => [input], + () => [[input, input]], + ) + : call(() => [input]), + [`${name}#toString()`]: call(() => []), + }); +} +for (const [name, offer, other] of [ + ["Accept", "text/html", "application/json"], + ["AcceptCharset", "utf-8", "latin1"], + ["AcceptEncoding", "gzip", "br"], + ["AcceptLanguage", "en", "fr"], +] as const) { + fixtures[`binden:${name}.negotiate()`] = probed( + { + deep: [ + [0, 0], + [1, 0], + ], + }, + () => [[offer, other], offer], + () => [[offer], [offer, other]], + ); +} +define("binden:", { + "Authorization.isValidType()": probed( + { + untouched: { + 0: "a predicate of any value (whether it is a valid type): it gives `false` for anything but a string", + }, + }, + () => ["Basic"], + ), + "Forwarded.parse()": probed( + { deep: [[0, 0]] }, + () => ["for=192.0.2.1"], + () => [["for=192.0.2.1"]], + ), +}); + +/** The options of a `BindenError` (also of `context.throw()`), and their paths */ +function errorOptions(): Record { + return { + message: "message", + expose: true, + json: { a: 1 }, + cause: 1, + code: "code", + headers: { "x-a": "b", "x-b": ["c"] }, + }; +} +const errorPaths: readonly (readonly (number | string)[])[] = [ + ...keysAt(1, errorOptions()), + [1, "json", "a"], + [1, "headers", "x-a"], + [1, "headers", "x-b"], + [1, "headers", "x-b", 0], +]; +/** A cause is any value: it is kept as it is */ +const errorUntouched = { + "1.cause": "the cause, any value, kept as it is (as by `Error`)", + "1.json.a": "a value of the JSON body, any value, copied as it is", +}; + +/** + * The socket of a request, which Node.js keeps (as `request.socket`), and + * whose address binden reads (`ip`) + */ +const keptSocket: IKept = { + why: "the socket, kept by Node.js as `request.socket` (as for a plain request), whose address binden reads", + later: "ip", +}; + +/** + * The constructor of a response, given the options of Node.js (as by + * `node:http`), which it keeps as they are, and its request, which Node.js + * keeps (as `response.req`), and whose HTTP version binden reads before it + * sends `100 Continue` or `102 Processing` + */ +const responseConstructor = probed( + { + deep: [[1, "highWaterMark"]], + readers: { + "writeContinue()": (_, made) => { + (made as ServerResponse).writeContinue(); + }, + "writeProcessing()": (_, made) => { + (made as ServerResponse).writeProcessing(); + }, + }, + untouched: { + "1.highWaterMark": + "an option of Node.js, which Node.js keeps as it is (as for a plain response)", + }, + kept: { + 0: { + why: "the request, kept by Node.js as `response.req` (as for a plain response), whose HTTP version binden reads as it informs", + later: "writeContinue()", + }, + }, + }, + () => [new IncomingMessage(new Socket()), { highWaterMark: 1024 }], +); + +// The errors, the middleware and the stack +define("binden:", { + "new BindenError()": probed( + { deep: errorPaths, untouched: errorUntouched }, + () => [404], + () => [500, errorOptions()], + ), + "new BindenAbortError()": call(() => ["deadline"]), + "new Middleware()": probed( + { + deep: [ + [0, "disabled"], + [0, "ignore_errors"], + ], + io: true, + readers: { + // Disabled, and run by an application (see `routed()`): binden logs + // that it skips it by its own path + "a disabled run": (_, made) => { + Reflect.set(made as object, "disabled", true); + return routed(made); + }, + }, + }, + () => [], + () => [{ disabled: false, ignore_errors: false }], + ), + "new Stack()": call(() => []), + // Its middlewares run for a request of a path that they match (see + // `stacked()`) + "Stack#use()": probed( + { + io: true, + readers: { "a request": (receiver) => stacked(receiver, "/a") }, + kept: { + 0: keptMiddleware("a request"), + 1: keptMiddleware("a request"), + 2: keptMiddleware("a request"), + }, + }, + () => [middleware], + () => ["/a", middleware, middleware], + () => [/^\/a/u, middleware], + ), + "Stack#route()": probed( + { + io: true, + readers: { "a request": (receiver) => stacked(receiver, "/a/b") }, + kept: { + 1: keptMiddleware("a request"), + 2: keptMiddleware("a request"), + }, + }, + () => ["/a/:b", middleware, middleware], + ), + "Stack#mount()": probed( + { + io: true, + readers: { "a request": (receiver) => stacked(receiver, "/a/b") }, + kept: { + 1: keptMiddleware("a request"), + 2: keptMiddleware("a request"), + }, + }, + () => ["/a", middleware, middleware], + ), + // Of a middleware that it has (whose removal its stack shows) + "Stack#off()": probed( + { + readers: { stack: (receiver) => (receiver as IStacked).stack.length }, + untouched: { + 1: { + why: "the middleware to remove, compared by identity: any other value removes nothing", + later: "stack", + }, + }, + }, + (_, receiver) => { + (receiver as IStacked).use(middleware); + return [middleware]; + }, + (_, receiver) => { + (receiver as IStacked).use("/a", middleware); + return ["/a", middleware]; + }, + ), + "new Router()": probed( + { deep: [[0, "guarded"]] }, + () => [], + () => [{ guarded: true }], + ), + "Router#guarded =": probed( + { + untouched: { + 0: "read by Boolean() (README, Lifecycle), which runs no code of the application", + }, + }, + () => [true], + ), + // A function of the middlewares of a method + "Router#middlewares": probed({ returned: call(() => ["GET"]) }, () => []), + // Its middlewares run by an application (see `routed()`) + "Router#on()": probed( + { + io: true, + readers: { "a request": (receiver) => routed(receiver) }, + kept: { + 1: keptMiddleware("a request"), + 2: keptMiddleware("a request"), + }, + }, + () => ["GET", middleware, middleware], + ), + // Of a middleware that it has (whose removal its middlewares show) + "Router#off()": probed( + { + readers: { + "middlewares()": (receiver) => + (receiver as IRouted).middlewares("GET").length, + }, + untouched: { + 1: { + why: "the middleware to remove, compared by identity: any other value removes nothing", + later: "middlewares()", + }, + }, + }, + (_, receiver) => { + (receiver as IRouted).on("GET", middleware); + return ["GET", middleware]; + }, + ), + "new Binden()": probed( + { + deep: keysAt(0, bindenOptions(), [ + "routing.case_sensitive", + "routing.strict", + "access_log.level", + "access_log.path", + ...requestNested, + ]), + // A request that it handles (e.g. its access log, through its logger, + // see `remembered`, and the 404 of its error handler), and one of + // another kind given to the listener of `callback()` + io: true, + readers: { + "request()": (_, made) => handled(made), + "the access log": async (_, made) => { + remembered.extra = null; + await handled(made); + return remembered.extra; + }, + "a foreign request": (_, made) => foreignRequest(made), + }, + kept: { + 0: { + why: "the options, read once, and kept by the closure of the constructor, which the classes of its requests and responses share: what a request uses later is what they hold (its functions and its logger)", + later: "request()", + }, + "0.error_handler": { + why: "the error handler, kept, and called for a request that fails (e.g. a 404)", + later: "request()", + ignores: ignoredBy("error_handler", "README, Errors"), + }, + "0.state": { + why: "what makes the state of a request, kept, and called for each request", + later: "request()", + }, + "0.allowed_hosts": { + why: "the hosts allowed, kept, and checked for each request", + later: "request()", + }, + "0.logger": { + why: "the logger, kept, and used for the records of each request", + later: "the access log", + }, + "0.access_log": { + why: "the options of the access log, read once", + instead: "unread", + }, + "0.routing": { + why: "the options of the routing, read once", + instead: "unread", + }, + "0.request_id": { + why: "the options of the request id, read once", + instead: "unread", + }, + "0.query": { + why: "the options of the query, read once", + instead: "unread", + }, + }, + }, + // The instance of the prototype members: one that logs nothing but the + // errors of its listener (see `listened`) + () => [{ logger: recording }], + () => [], + () => [bindenOptions()], + ), + // A server registered for `shutdown()` (see `listen()`) + "Binden#createServer()": probed( + { members: { "listen()": listen((env) => env.server()) } }, + () => [{}], + ), + "Binden#createSecureServer()": probed( + { members: { "listen()": listen((env) => env.server()) } }, + () => [{}], + ), + "Binden#serverOptions()": probed( + { + // The classes of the application, of its requests and responses + members: { + "IncomingMessage()": probed( + { + readers: { + ip: (_, made): unknown => Reflect.get(made as object, "ip"), + }, + kept: { 0: keptSocket }, + }, + () => [new Socket()], + ), + "ServerResponse()": responseConstructor, + }, + }, + () => [], + ), +}); + +/** + * The objects that a call has returned, by what has made them (the receiver + * of the call): e.g. the application of the listener of `callback()` + */ +const origins = new WeakMap(); + +/** The application of the listener of `callback()` (see `origins`) */ +function applicationOf(listener: unknown): { + serverOptions(): Record< + "IncomingMessage" | "ServerResponse", + new (...values: unknown[]) => IncomingMessage + >; +} { + return origins.get(listener as object) as ReturnType; +} + +/** + * Where it is documented that a serializer never fails: a value of another + * kind is reduced to its type + */ +const reduced = { + call: "README, Logging and Serializers: a value of another kind is reduced to its type", +}; +/** + * What a serializer of a value of an exchange adds to its calls: the record + * that it gives is read as pino writes it (`JSON.stringify()`), once the call + * has returned, and the value (which the server of its exchange holds) is + * read by the call alone, never by its record + */ +function recordOf(what: string): IFixtureOptions { + return { + infallible: reduced, + readers: { "the record": (_, record) => JSON.stringify(record) }, + held: { + 0: { + why: `${what} of an exchange, which its server holds as it handles it: the serializer gives a record of it, and keeps none of it`, + instead: "unread", + }, + }, + }; +} +/** The serializers of @binden/logger (its `serializers`, and of `createSerializers()`) */ +const loggerSerializing: Record = { + "err()": probed({ infallible: reduced }, () => [new Error("An error")]), + "error()": probed({ infallible: reduced }, () => [new Error("An error")]), + "request()": probed(recordOf("the request"), async (env) => [ + (await env.exchange()).request, + ]), + "response()": probed(recordOf("the response"), async (env) => [ + (await env.exchange()).response, + ]), +}; +/** The serializers of binden (its `serializers`, and of `createSerializers()`) */ +const serializing: Record = { + ...loggerSerializing, + "body()": probed( + { + untouched: { 0: "redacts any body: it never reads it" }, + infallible: reduced, + }, + () => ["body"], + ), + "context()": probed(recordOf("the context"), async (env) => [ + (await env.exchange()).context, + ]), +}; +/** + * What `createSerializers()` adds to its calls: its options are read once + * (also once its serializer of an error has run) + */ +const serialized: IFixtureOptions = { + deep: [[0, "error_details"]], + readers: { + "error()": (_, made) => + (made as { error(error: unknown): unknown }).error(new Error("An error")), + }, + kept: { 0: { why: "the options, each read once", instead: "unread" } }, +}; +/** The fixtures of the members of `serializers` */ +function serializersOf( + defined: Record, +): Record { + return Object.fromEntries( + Object.entries(defined).map(([name, fixture]) => [ + `serializers.${name}`, + fixture, + ]), + ); +} + +for (const verb of [ + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "trace", +]) { + // Its middlewares run for a request of its method (see `routed()`) + fixtures[`binden:Router#${verb}()`] = probed( + { + io: true, + readers: { + "a request": (receiver) => routed(receiver, verb.toUpperCase()), + }, + kept: { + 0: keptMiddleware("a request"), + 1: keptMiddleware("a request"), + }, + }, + () => [middleware, middleware], + ); +} +/** + * A reader of `callback()`: its listener handles a request of the application + * (of no connection, as by its `returned` fixture) + */ +function requested(receiver: unknown, listener: unknown): unknown { + const { IncomingMessage: Request, ServerResponse: Response } = ( + receiver as ReturnType + ).serverOptions(); + const request = new Request(new Socket()); + request.method = "GET"; + request.url = "/"; + return (listener as IFunction)(request, new Response(request)); +} +define("binden:", { + // Its listener of the requests of the server (given a request and a + // response of the classes of the application, so that it handles them), + // and the `listen()` of the server that it registers for `shutdown()` + "Binden#callback()": probed( + { + installed: { "0.listen": listen((env) => env.server()) }, + returned: probed( + { + // A request or a response of another kind (also a proxy of one of + // the application) is logged, and its connection closed: always a + // response, given with the request of the application + refused: (): boolean => + listened.request?.socket.destroyed === true && + listened.errors.includes(otherKind), + refuses: ["1"], + }, + (_, listener) => { + const { IncomingMessage: Request, ServerResponse: Response } = + applicationOf(listener).serverOptions(); + const request = new Request(new Socket()); + request.method = "GET"; + request.url = "/"; + listened.request = request; + listened.errors.length = 0; + return [request, new Response(request)]; + }, + ), + readers: { "a request": requested }, + kept: { + 0: { + why: "the server, kept by the listener that it returns (and registered for `shutdown()`), which never reads it as it handles a request", + instead: "unread", + }, + }, + }, + // Of the application: what the call keeps of it is told + (env) => [env.ownServer()], + ), +}); + +// The functions of binden +define("binden:", { + "createSerializers()": probed({ ...serialized, members: serializing }, () => [ + { error_details: true }, + ]), + "described()": probed( + { + untouched: { + 0: "describes any value, without running code of the application", + }, + }, + () => ["a"], + ), + "formatStack()": call(() => [new Error("An error")]), + "formatting()": call(() => [one]), + "framingLength()": call(() => ["1"]), + "isClosing()": probed( + { + untouched: { + 0: { + why: "a predicate of any value (whether it is a server that shuts down)", + instead: "a server that shuts down", + }, + }, + alternatives: { + // Told by identity: neither an object derived from it nor a proxy + // of it, and no trap runs + "a server that shuts down": async (): Promise => { + const env = environment(); + try { + const created = await env.listening(); + await (binden.shutdown as IFunction)(created, { + timeout: 50, + cleanup_timeout: 50, + }); + const given = hostile("throwing", null); + const told = [ + created, + Object.create(created) as object, + new Proxy(created, {}), + given.value, + ].map((value) => (binden.isClosing as IFunction)(value)); + return told.join() === "true,false,false,false" && + given.thrown.length === 0 + ? [] + : [ + `tells ${told.join()} (traps: ${String(given.thrown.length)})`, + ]; + } finally { + for (const cleanup of env.cleanups) { + await cleanup(); + } + } + }, + }, + }, + (env) => [env.server()], + ), + "shutdown()": probed( + { + io: true, + // Of a server that has shut down + installed: { "0.listen": listen((env) => env.server()) }, + readers: { + "isClosing()": (_, __, [given]) => + (binden.isClosing as IFunction)(given), + }, + held: { + 0: { + why: "a listening server, which its handle holds until it is closed: read until it has shut down, never once it has", + instead: "settled", + }, + }, + deep: [ + [1, "timeout"], + [1, "cleanup_timeout"], + ], + }, + async (env) => [ + await env.listening(), + { timeout: 50, cleanup_timeout: 50 }, + ], + ), + "validateRequestOptions()": probed( + { deep: keysAt(0, requestOptions(), requestNested) }, + () => [requestOptions()], + ), + "validateResponseOptions()": probed({ deep: [[0, "etag"]] }, () => [ + { etag: true }, + ]), + ...serializersOf(serializing), +}); + +// The request, the response and the context +define("binden:", { + "new BindenRequest()": probed({ kept: { 0: keptSocket } }, () => [ + new Socket(), + ]), + "BindenRequest#accepts()": call(() => ["text/html", "application/json"]), + "BindenRequest#acceptsCharsets()": call(() => ["utf-8", "latin1"]), + "BindenRequest#acceptsEncodings()": call(() => ["gzip", "br"]), + "BindenRequest#acceptsLanguages()": call(() => ["en", "fr"]), + "BindenRequest#body =": probed( + { + untouched: { + 0: { + why: "the body, any value (e.g. of a parser), kept as it is", + later: "body", + }, + }, + }, + () => [{ a: 1 }], + ), + "BindenRequest#header()": call(() => ["accept"]), + "new BindenResponse()": responseConstructor, +}); + +const Cookie = binden.Cookie as new (init: object) => object; +const BindenRequest = binden.BindenRequest as new ( + socket: Socket, +) => IncomingMessage; +const BindenResponse = binden.BindenResponse as new ( + request: IncomingMessage, +) => ServerResponse; +/** A stack, of what its fixtures use */ +interface IStacked { + readonly stack: readonly unknown[]; + use(...values: unknown[]): unknown; +} +/** A router, of what its fixtures use */ +interface IRouted { + middlewares(method: string): readonly unknown[]; + on(...values: unknown[]): unknown; +} + +/** + * Fails the work of the middleware of the exchange of a response (see + * `failingWork`), so that binden replies by its default error reply: the head + * that its client gets (`""` for none), or `null` for a response of no + * exchange + */ +async function errorReplyOf(response: unknown): Promise { + const fail = isObject(response) ? failingWork.get(response) : null; + if (typeof fail !== "function") { + return null; + } + return bounded( + fail(new Error("A failure of the test")), + ioMs, + "The error reply", + ); +} + +/** + * A reader of a call given a context, a request or a response of an exchange + * (or a hostile value that stands for one, see `standsFor`): the status line + * of the default error reply of that exchange (see `errorReplyOf()`), which + * runs what the call has left on it (e.g. the writers of a compression), or + * `null` for a value of no exchange + */ +async function replied( + _: unknown, + __: unknown, + values: readonly unknown[], +): Promise { + const [given] = values; + const valid = isObject(given) ? (standsFor.get(given) ?? given) : null; + const head = await errorReplyOf( + valid === null ? null : exchangeResponses.get(valid), + ); + return head === null ? null : (head.split("\r\n")[0] ?? ""); +} + +/** The valid cookies of the fixtures (see `committed()`) */ +const validCookies = new WeakSet(); +/** A valid cookie (see `validCookies`) */ +function validCookie(): object { + const cookie = new Cookie({ key: "a", value: "b" }); + validCookies.add(cookie); + return cookie; +} + +/** + * A reader of the cookies of the response of an exchange (see `origins`): + * fails the work of its middleware, so that binden commits the head by its + * default error reply (see `failingWork`), whose status line it gives. Its + * client must get that `500`, with the `Set-Cookie` of a valid cookie given + * (see `validCookies`): a cookie that fails as it is committed (e.g. its + * `toString()` throws) is dropped from it, never the reply itself. + */ +async function committed( + receiver: unknown, + _: unknown, + values: readonly unknown[], +): Promise { + const head = await errorReplyOf( + isObject(receiver) ? origins.get(receiver) : null, + ); + if (head === null) { + throw new TypeError("The cookies of no exchange"); + } + const [line = ""] = head.split("\r\n"); + const [given] = values; + const valid = + isObject(given) && validCookies.has(given) + ? (given as { toString(): string }) + : null; + const failure = line.startsWith("HTTP/1.1 500 ") + ? valid !== null && + !head.split("\r\n").includes(`Set-Cookie: ${valid.toString()}`) + ? "The error reply has dropped a valid cookie" + : null + : `Its client has got no error reply (${line === "" ? "its connection has closed" : line})`; + if (failure !== null) { + // Formatted, as an error of the reader itself (see `infallible`) + const error = new Error(failure); + Reflect.get(error, "stack"); + throw error; + } + return line; +} + +/** The members of the cookies of a response (a `Set` of `Cookie`) */ +const cookieMembers: Record = { + // Of a cookie that they have + "delete()": probed( + { + readers: { size: (receiver) => (receiver as Set).size }, + untouched: { + 0: { + why: "the cookie to delete, compared by identity (a `Set`): any other value deletes nothing", + later: "size", + }, + }, + }, + (_, receiver) => { + const cookie = new Cookie({ key: "a", value: "b" }); + (receiver as Set).add(cookie); + return [cookie]; + }, + ), + "clear()": call(() => []), +}; +// It returns the cookies, and keeps a cookie (any object) until the head is +// committed +cookieMembers["add()"] = probed( + { + members: cookieMembers, + readers: { "the commit": committed }, + converts: { 0: cookieValue }, + infallible: { + "the commit": + "README, BindenResponse and Errors: a cookie that fails as the head is committed is dropped from the default error reply, which is sent", + }, + kept: { + 0: { + why: "the cookie, kept until the head is committed, which reads its `toString()`", + later: "the commit", + }, + }, + }, + () => [validCookie()], +); +const cookies = probed({ members: cookieMembers }, () => []); + +/** The fixtures of the methods that the response and the context share */ +const replies: Record = { + "status()": call(() => [201]), + "type()": call(() => ["text/plain"]), + "attachment()": call( + () => [], + () => ["a.txt"], + ), + "vary()": call(() => ["accept", "accept-encoding"]), + "clearCookie()": probed( + { + deep: ["domain", "path", "secure", "same_site", "partitioned"].map( + (key) => [1, key], + ), + converts: { 0: headerInit, "1.domain": headerInit, "1.path": headerInit }, + untouched: Object.fromEntries( + ["1.secure", "1.partitioned"].map((key) => [ + key, + "read by Boolean() (README, Lifecycle), which runs no code of the application", + ]), + ), + }, + () => [ + "a", + { + domain: "example.com", + path: "/", + secure: true, + same_site: "None", + partitioned: true, + }, + ], + ), + "setHeader()": probed( + { + converts: { 0: nodeCoercion, 1: nodeCoercion }, + node: plainly("setHeader"), + }, + () => ["x-a", "b"], + ), + "send()": probed( + { io: true, loses: [2] }, + () => [], + () => ["text"], + () => [viewOf()], + ), + "json()": probed( + { + io: true, + deep: [ + [0, "a"], + [0, "b", 0], + ], + }, + () => [{ a: 1, b: [1] }], + () => [{ a: 1, b: [1] }, (data: unknown): string => JSON.stringify(data)], + ), + "text()": probed({ io: true }, () => ["text"]), + "html()": probed({ io: true }, () => ["

"]), + "form()": probed({ io: true }, () => [new URLSearchParams("a=b")]), + "redirect()": probed( + { io: true, deep: [[2, "allow_external"]] }, + () => ["/a"], + () => ["/a", 302, { allow_external: false }], + ), + "sendFile()": probed( + { + io: true, + deep: [ + "root", + "dotfiles", + "etag", + "validator", + "last_modified", + "cache_control", + "max_age", + "immutable", + "content_type", + ].map((key) => [1, key]), + }, + () => [join(import.meta.dirname, "public-surface.ts")], + () => [ + "public-surface.ts", + { + root: import.meta.dirname, + dotfiles: "deny", + etag: true, + last_modified: true, + cache_control: "no-cache", + content_type: "text/plain", + }, + ], + () => [ + "public-surface.ts", + { + root: import.meta.dirname, + etag: false, + validator: '"tag"', + max_age: 60, + immutable: true, + }, + ], + ), +}; + +/** Why an argument of the logger of a context examines no value */ +const logged = + "given as it is to the logger of the application: a call of the logger of a context never fails (README, Context)"; +/** Where it is documented that the logger of a context never fails */ +const neverFails = "README, Context and Logging: its calls never fail"; +/** The methods of the logger of a context (whose calls never fail) */ +const contextLogger: Record = Object.fromEntries( + ["fatal", "error", "warn", "info", "debug", "trace"].map((level) => [ + `${level}()`, + probed( + { + // What the logger of the application has got (see `remembering`) + readers: { "the record": () => remembered.extra }, + untouched: { 0: logged, 1: { why: logged, later: "the record" } }, + infallible: { call: neverFails }, + }, + () => ["message", { a: 1 }], + ), + ]), +); +// A child is a logger of the same kind +contextLogger["child()"] = probed( + { + members: contextLogger, + readers: { "the bindings": () => remembered.bindings }, + untouched: { 0: { why: logged, later: "the bindings" } }, + infallible: { call: neverFails }, + }, + () => [{ a: 1 }], +); + +define("binden:Context#", { + ...replies, + "done =": probed( + { + untouched: { + 0: "read by Boolean() (README, Lifecycle), which runs no code of the application", + }, + }, + () => [true], + ), + // A reply once the deadline has passed is late + "setTimeout()": probed( + // Its deadline passes as the reader waits (also on a loaded machine) + { io: true, readers: { "a late reply": lateReply } }, + () => [1000], + ), + // Its cleanup runs once the response is closed + "onCleanup()": probed( + { + io: true, + readers: { "an ended response": endedResponse }, + kept: { + 0: { + why: "the cleanup, kept, and run once the response is closed", + later: "an ended response", + ignores: ignoredBy("cleanup", "README, Context: it is awaited"), + }, + }, + }, + () => [noop], + ), + "throw()": probed( + { throws: true, deep: errorPaths, untouched: errorUntouched }, + () => [418], + () => [418, errorOptions()], + ), + log: probed( + { members: contextLogger, infallible: { call: neverFails } }, + () => [], + ), +}); + +// The internal functions of binden that write the records of its packages +define("binden:", { + // The guarded paths of the records of the packages (see `record()`): they + // never fail, whatever they are given + "record()": probed( + { + readers: { "the record": () => remembered.extra }, + untouched: { + 1: "a level, compared with the known ones (never converted): another one drops the record", + 2: logged, + 3: { why: logged, later: "the record" }, + }, + infallible: { call: neverFails }, + }, + () => [remembering, "info", "message", { a: 1 }], + ), + "logTo()": probed( + { + readers: { "the record": () => remembered.extra }, + untouched: { + 1: "a level, compared with the known ones (never converted): another one drops the record", + 2: logged, + 3: { why: logged, later: "the record" }, + }, + infallible: { call: neverFails }, + }, + async (env) => { + const { request, response } = await env.exchange(); + const context = Reflect.construct(binden.Context as IFunction, [ + { request, response, logger: remembering }, + ]) as object; + return [context, "info", "message", { a: 1 }]; + }, + ), + "childLogger()": probed( + { + // A logger of binden, as the one of a context + members: contextLogger, + readers: { "the bindings": () => remembered.bindings }, + untouched: { 1: { why: logged, later: "the bindings" } }, + infallible: { call: neverFails }, + }, + () => [remembering, { a: 1 }], + ), +}); +define("binden:", { + "new Context()": probed( + { + deep: [ + ...["request", "response", "logger", "state", "report"].map((key) => [ + 0, + key, + ]), + // A method of a logger of binden (that an object derived from it + // overrides, see `replaced()`) + [0, "logger", "child"], + ], + untouched: { + "0.state": { + why: "the state of the application, any value, kept as it is by the first context of a request (and ignored by the others, which share it)", + later: "state", + }, + // Wrapped as it is (a logger of binden, told by its brand, is + // used as it is) + "0.logger": { + why: "the logger, wrapped as it is: its calls never fail", + later: "log.error()", + }, + // Its `child()` makes the logger of the context (see `log`) + "0.logger.child": { + why: "the logger, wrapped as it is: its calls never fail", + later: "log", + }, + }, + readers: { + // Its logger, through which every record goes + "log.error()": (_, made) => { + (made as { log: { error(message: string): void } }).log.error( + "message", + ); + }, + // A cleanup that fails: the report of the request (of a context that + // the application has made, its own, or by default one that logs) + "a failing cleanup": (_, made) => { + failedCleanup(made); + }, + }, + infallible: { log: neverFails, "log.error()": neverFails }, + alternatives: { + // A proxy of valid options, which records what is read once the + // context is made, as its logger, its state and its report are used + "options read once": (): string[] => { + const request = new BindenRequest(new Socket()); + const reads: string[] = []; + let made = false; + const record = (what: string, key: PropertyKey): void => { + if (made) { + reads.push(`${what} ${String(key)}`); + } + }; + const options = new Proxy( + { + request, + response: new BindenResponse(request), + logger: silent, + state: {}, + }, + { + get(target, key, receiver): unknown { + record("get", key); + return Reflect.get(target, key, receiver) as unknown; + }, + has(target, key): boolean { + record("has", key); + return Reflect.has(target, key); + }, + ownKeys(target): (string | symbol)[] { + record("ownKeys", ""); + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor( + target, + key, + ): PropertyDescriptor | undefined { + record("getOwnPropertyDescriptor", key); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ); + const context = Reflect.construct(binden.Context as IFunction, [ + options, + ]) as object; + made = true; + (context as { log: { error(message: string): void } }).log.error( + "message", + ); + if (!isObject(Reflect.get(context, "state"))) { + reads.push("(no state)"); + } + failedCleanup(context); + return reads.length === 0 + ? [] + : [`reads its options once it is made: ${reads.join(", ")}`]; + }, + }, + kept: { + // Kept by the report that the constructor makes (of the first + // context of a request): every value that would tell a later read + // is refused (a proxy of its request or of its response) + 0: { why: "the options, each read once", instead: "options read once" }, + "0.report": { + why: "the report of the request, kept by its first context, and called once a cleanup fails", + later: "a failing cleanup", + ignores: ignoredBy("report", "README, Context"), + }, + }, + }, + async (env) => { + const { request, response } = await env.exchange(); + return [{ request, response, logger: silent, state: {}, report: noop }]; + }, + // Given the logger of a context (of binden, e.g. to derive one from it) + async (env) => { + const { request, response, context } = await env.exchange(); + const logger: unknown = Reflect.get(context, "log"); + return [{ request, response, logger }]; + }, + // The first context of a request (which creates what its contexts + // share, e.g. its state), by default with a report that logs + () => { + const request = new BindenRequest(new Socket()); + return [{ request, response: new BindenResponse(request), state: {} }]; + }, + // The first context of a request, with a report of its own + () => { + const request = new BindenRequest(new Socket()); + return [{ request, response: new BindenResponse(request), report: noop }]; + }, + ), +}); + +/** Why a listener is kept, and what checks it */ +const keptListener: IKept = { + why: "a listener, which Node.js keeps (as for a plain response), and calls as it is", + instead: "callbacks", +}; + +/** + * A fixture of a listener method (whose event name Node.js converts, as for + * the method of a plain response) + */ +function listening(name: string): IFixture { + return probed( + { + converts: { 0: nodeCoercion }, + node: plainly(name), + readers: { "a later event": laterEvent("custom") }, + kept: { 1: keptListener }, + callbacks: { 1: "the listener, which Node.js calls as it is" }, + }, + () => ["custom", noop], + ); +} +/** The callbacks of `write()` and `end()` (by position) */ +const written = { + 1: "the callback, which Node.js calls as it is", + 2: "the callback, which Node.js calls as it is", +}; + +/** The same calls of `writeHead()` (also of an installed one, see `installed`) */ +const heads: readonly ICall[] = [ + (): readonly unknown[] => [200], + (): readonly unknown[] => [200, "OK", { "x-a": "b" }], + (): readonly unknown[] => [200, { "x-a": "b" }], + (): readonly unknown[] => [200, ["x-a", "b"]], +]; +/** Why the reason of a cancellation examines no value */ +const cancelled = + "the reason of the cancellation, any value, which the sends reject with as it is"; +/** Whether Node.js has `writeInformation()` (from 24.18.0) */ +const writeInformation = + typeof Reflect.get(ServerResponse.prototype, "writeInformation") === + "function"; +/** What Node.js does with a reason of `writeHead()` that is not a string */ +const reasonUntouched = + "a reason that is not a string is taken as the headers, as Node.js does, which ignores headers that are not an object"; +/** + * The readers of what a check of a response stores that mutate it later + * (e.g. after `[kSeal]()`): its status and a header + */ +const mutations: Readonly> = { + "status()": (receiver) => + Reflect.apply( + Reflect.get(receiver as object, "status") as IFunction, + receiver, + [201], + ), + "setHeader()": (receiver) => + (receiver as ServerResponse).setHeader("x-a", "b"), +}; + +/** How many times the observer of the fixture of `[kUnlisten]()` has run, by response */ +const observed = new WeakMap(); + +/** The sends in progress of the responses (see `pendingSend()`), by them */ +const pendingSends = new WeakMap< + object, + { readonly source: Readable; readonly settled: Promise } +>(); +/** Starts a send of a stream that stays in progress until its source fails */ +function pendingSend(receiver: unknown): void { + const source = new Readable({ read: noop }); + const sent = (receiver as { send(body: unknown): Promise }).send( + source, + ); + pendingSends.set(receiver as object, { + source, + // In a holder: a promise never adopts what it has rejected with + settled: sent.then( + () => null, + (error: unknown) => [error], + ), + }); +} +/** + * What a send in progress of a response has rejected with (`null` once it + * resolves), once its source fails (e.g. a reason that a cancellation has + * recorded, see `[kCancelling]()`): described (see `describe()`), never + * given to a promise, which would read the `then` of an object + */ +async function settledSend(receiver: unknown): Promise { + const pending = pendingSends.get(receiver as object); + pending?.source.destroy(); + const settled = await pending?.settled; + return Array.isArray(settled) ? describe(settled[0]) : null; +} + +/** The symbol of a member of a response that binden does not export */ +function responseSymbol(description: string): symbol { + const found = Object.getOwnPropertySymbols(BindenResponse.prototype).find( + (key) => key.description === description, + ); + if (typeof found === "undefined") { + throw new TypeError(`No symbol ${description}`); + } + return found; +} +const kOwnership = responseSymbol("binden.ownership"); +const kRevoke = responseSymbol("binden.revoke"); +const kListen = binden.kListen as symbol; + +/** The handlers of `[kOwnership]()` (see `IOwnership` of binden) */ +const ownershipFields = [ + "fail", + "guards", + "replier", + "native", + "debug", + "warn", + "note", +] as const; + +/** + * Handlers of `[kOwnership]()` that handle nothing, but leave a pipe of a + * work whose source has listener methods of its own to Node.js (`native`), + * or refuse it + */ +function ownershipOf(native: boolean): Record { + return { + fail: (): boolean => false, + guards: (): boolean => false, + replier: (): null => null, + native: (): boolean => native, + debug: noop, + warn: noop, + note: noop, + }; +} + +/** The errors that the `fail()` of the handlers of a response has been given */ +const failedWith = new WeakMap(); +/** A `fail()` of handlers that records the error that it is given */ +function failing(receiver: unknown): IFunction { + return (_: unknown, error: unknown): boolean => { + failedWith.set(receiver as object, error); + return false; + }; +} + +/** Runs a function as the work of the request of a response (see `works`) */ +function asWork(receiver: unknown, fn: () => unknown): unknown { + const work = works.get(receiver as object); + return typeof work === "undefined" ? fn() : work(fn); +} + +/** + * A pipe into a response, as the work of its request, of a source whose + * listener methods are its own (e.g. bound by OpenTelemetry), which it + * cannot adopt: left to Node.js, or refused (see `IOwnership.native()`) + */ +function unadoptablePipe(receiver: unknown): unknown { + const source = new Readable({ read: noop }); + const addListener = Reflect.get(Readable.prototype, "on") as IFunction; + source.on = function on(...values: unknown[]): Readable { + return Reflect.apply(addListener, this, values) as Readable; + }; + return asWork(receiver, () => source.pipe(receiver as ServerResponse)); +} + +/** The readers of the handlers of `[kOwnership]()`, by their names */ +const ownershipReaders: Readonly> = { + ...mutations, + "destroy()": (receiver) => (receiver as ServerResponse).destroy(), + // Of an event whose listener and observer both throw + "emit()": (receiver) => { + Reflect.apply( + Reflect.get(receiver as object, kListen) as IFunction, + receiver, + [ + "custom", + (): never => { + throw new Error("An observer"); + }, + { once: false }, + ], + ); + (receiver as ServerResponse).on("custom", () => { + throw new Error("A listener"); + }); + try { + (receiver as ServerResponse).emit("custom"); + } catch { + // The listener's + } + }, + "pipe() of an unadoptable source": unadoptablePipe, + "pipe() of a source": (receiver) => { + const source = new Readable({ read: noop }); + return asWork(receiver, () => source.pipe(receiver as ServerResponse)); + }, +}; +/** The reader that calls each handler of `[kOwnership]()` */ +const ownershipReader: Readonly< + Record<(typeof ownershipFields)[number], string> +> = { + fail: "pipe() of an unadoptable source", + guards: "pipe() of a source", + replier: "destroy()", + native: "pipe() of an unadoptable source", + debug: "emit()", + warn: "pipe() of an unadoptable source", + note: "pipe() of an unadoptable source", +}; + +/** + * A send of a stream that fails before its first chunk (e.g. after the + * representation of its body is chosen, which the response forgets): the + * failure that the send rejects with, its source's or one that it has + * formatted (e.g. of a destroyed response, see the fixture of `send()`), is + * not one of the reader, whose own failures are what it does not format and + * the errors of the test + */ +async function failedSend(receiver: unknown): Promise { + const failure = new Error("A failure of the source"); + const source = new Readable({ + read(): void { + this.destroy(failure); + }, + }); + try { + await (receiver as { send(body: unknown): Promise }).send(source); + } catch (error: unknown) { + if ( + error !== failure && + !(!isProxy(error) && isNativeError(error) && formatted.has(error)) + ) { + throw error; + } + } +} + +define("binden:BindenResponse#", { + ...replies, + cookies, + "set()": probed( + { + deep: [ + [0, "x-a"], + [0, "x-b"], + [0, "x-b", 0], + ], + // Set by `setHeader()` of Node.js + converts: { + "0.x-a": nodeCoercion, + "0.x-b": nodeCoercion, + "0.x-b.0": nodeCoercion, + }, + node([headers], plain): void { + const response = plain.response(); + for (const [name, value] of Object.entries(headers as object)) { + response.setHeader(name, value as string); + } + }, + }, + () => [{ "x-a": "b", "x-b": ["c", "d"] }], + ), + "send()": probed( + { io: true, loses: [3] }, + () => [], + () => ["text"], + () => ["74657874", "hex"], + () => [viewOf()], + ), + "writeHead()": probed( + { + deep: [ + [1, "x-a"], + [2, "x-a"], + [1, 0], + [1, 1], + ], + converts: { + 0: nodeCoercion, + "1.x-a": nodeCoercion, + "2.x-a": nodeCoercion, + "1.0": nodeCoercion, + 1.1: nodeCoercion, + }, + node: plainly("writeHead"), + untouched: { 1: reasonUntouched }, + }, + ...heads, + ), + "writeHeader()": probed( + { + deep: [ + [1, "x-a"], + [2, "x-a"], + ], + converts: { + 0: nodeCoercion, + "1.x-a": nodeCoercion, + "2.x-a": nodeCoercion, + }, + // An alias of `writeHead()` (which Node.js 26 no longer has) + node: plainly("writeHead"), + untouched: { 1: reasonUntouched }, + }, + ...heads.slice(0, 3), + ), + "write()": probed( + { callbacks: written, untouched: { 2: ignoredCallback } }, + () => ["text"], + () => ["text", noop], + () => ["74657874", "hex", noop], + ), + "end()": probed( + { + callbacks: { + 0: "the callback, which Node.js calls as it is", + ...written, + }, + untouched: { 2: ignoredCallback }, + }, + () => [], + () => [noop], + () => ["text", noop], + () => ["74657874", "hex", noop], + ), + "flushHeaders()": call(() => []), + "getHeader()": call(() => ["x-a"]), + "getHeaders()": call(() => []), + "appendHeader()": probed( + { + converts: { 0: nodeCoercion, 1: nodeCoercion }, + node: plainly("appendHeader"), + }, + () => ["x-a", "b"], + () => ["x-a", ["b", "c"]], + ), + "setHeaders()": call( + () => [new Headers({ "x-a": "b" })], + () => [new Map([["x-a", "b"]])], + ), + // Its callback listens for the timeout of the response + "setTimeout()": probed( + { + readers: { "a later timeout": laterEvent("timeout") }, + kept: { 1: keptListener }, + callbacks: { + 1: "the listener of the timeout, which Node.js calls as it is", + }, + }, + () => [1000, noop], + ), + "removeHeader()": call(() => ["x-a"]), + "addTrailers()": probed( + { + deep: [ + [0, "x-a"], + [0, 0, 0], + [0, 0, 1], + ], + converts: { + "0.x-a": nodeCoercion, + "0.0.0": nodeCoercion, + "0.0.1": nodeCoercion, + }, + node: plainly("addTrailers"), + }, + () => [{ "x-a": "b" }], + () => [[["x-a", "b"]]], + ), + "destroy()": probed( + { + readers: { errored: (receiver) => (receiver as ServerResponse).errored }, + kept: { + 0: { + why: "the error, kept by Node.js as `response.errored` (as for a plain response), and given to the socket that the response destroys", + later: "errored", + }, + }, + }, + () => [], + () => [new Error("An error")], + ), + "writeContinue()": probed( + { + callbacks: { 0: "the callback, which Node.js calls as it is" }, + untouched: { 0: ignoredCallback }, + }, + () => [noop], + ), + "writeProcessing()": probed( + { + callbacks: { 0: "the callback, which Node.js calls as it is" }, + untouched: { 0: ignoredCallback }, + }, + () => [noop], + ), + "writeEarlyHints()": probed( + { + deep: [[0, "link"]], + callbacks: { 1: "the callback, which Node.js calls as it is" }, + untouched: { 1: ignoredCallback }, + }, + () => [{ link: "; rel=preload" }, noop], + ), + // Of Node.js from 24.18.0: binden refuses it before (both overloads), and + // sends the one without headers as `(102, null, callback)` + "writeInformation()": probed( + writeInformation + ? { + callbacks: { + 1: "the callback, which Node.js calls as it is", + 2: "the callback, which Node.js calls as it is", + }, + untouched: { 2: ignoredCallback }, + } + : { + throws: true, + untouched: Object.fromEntries( + ["0", "1", "2"].map((position) => [ + position, + { + why: "binden refuses it on a Node.js without it, before reading its arguments", + instead: "a refusal before its arguments", + }, + ]), + ), + alternatives: { + // A `TypeError` that it formats, without running a trap of + // arguments whose every trap throws + "a refusal before its arguments": async (): Promise => { + const env = environment(); + const given = [0, 1, 2].map(() => hostile("throwing", null)); + let thrown: unknown = null; + try { + const { response } = await env.exchange(); + Reflect.apply( + Reflect.get(response, "writeInformation") as IFunction, + response, + given.map(({ value }) => value), + ); + } catch (error: unknown) { + thrown = error; + } finally { + for (const cleanup of env.cleanups.reverse()) { + await cleanup(); + } + } + const traps = given.reduce( + (sum, { thrown: trapped }) => sum + trapped.length, + 0, + ); + return isNativeError(thrown) && + thrown.name === "TypeError" && + formatted.has(thrown) && + traps === 0 + ? [] + : [`throws ${describe(thrown)} (traps: ${String(traps)})`]; + }, + }, + }, + () => [103, { link: "; rel=preload" }, noop], + () => [102, noop], + ), + "flush()": call(() => []), + "statusCode =": probed({ untouched: { 0: nodeField } }, () => [201]), + "statusMessage =": probed({ untouched: { 0: nodeField } }, () => ["Created"]), + "chunkedEncoding =": probed({ untouched: { 0: nodeField } }, () => [false]), + "maxRequestsOnConnectionReached =": probed( + { untouched: { 0: nodeField } }, + () => [false], + ), + "sendDate =": probed({ untouched: { 0: nodeField } }, () => [true]), + "shouldKeepAlive =": probed({ untouched: { 0: nodeField } }, () => [false]), + "strictContentLength =": probed({ untouched: { 0: nodeField } }, () => [ + false, + ]), + "useChunkedEncodingByDefault =": probed( + { untouched: { 0: nodeField } }, + () => [true], + ), + "addListener()": listening("addListener"), + "on()": listening("on"), + "once()": listening("once"), + "prependListener()": listening("prependListener"), + "prependOnceListener()": listening("prependOnceListener"), + "rawListeners()": probed( + { converts: { 0: nodeCoercion }, node: plainly("rawListeners") }, + () => ["custom"], + ), + "emit()": probed( + { + converts: { 0: nodeCoercion }, + node: plainly("emit"), + untouched: { 1: "an argument of the listeners, given as it is" }, + }, + () => ["custom", 1], + ), + "[binden.saveFraming]()": call(() => []), + "[binden.restoreFraming]()": call((_, receiver) => [ + Reflect.apply( + Reflect.get( + receiver as object, + binden.kSaveFraming as symbol, + ) as IFunction, + receiver, + [], + ), + ]), + // Checks the arguments of `writeHead()` as Node.js does + "[binden.validate_head]()": probed( + { + deep: [[2, "x-a"]], + converts: { 0: nodeCheck }, + node: plainly("writeHead"), + untouched: { 1: reasonUntouched }, + }, + () => [200, "OK", { "x-a": "b" }], + ), + // A check, called (guarded) as the response is mutated: one that is not a + // function (or that throws) refuses every mutation + "[binden.seal]()": probed( + { + readers: mutations, + untouched: { + 0: { + why: "a check, kept as it is, and called as the response is mutated", + later: "status()", + }, + }, + }, + () => [(): boolean => true], + ), + // The handlers of the framework, kept as they are, and called as it + // replies: `replier()` by `destroy()`, `debug()` for an observer that + // fails, and the others for a pipe of the work of the request (`note()` + // once `native()` leaves it to Node.js) + "[binden.ownership]()": probed( + { + deep: ownershipFields.map((field) => [0, field]), + readers: ownershipReaders, + untouched: { + 0: { + why: "the handlers, kept as they are", + later: "destroy()", + }, + ...Object.fromEntries( + ownershipFields.map((field) => [ + `0.${field}`, + { + why: "a handler, kept as it is, and called as the response replies", + later: ownershipReader[field], + }, + ]), + ), + }, + }, + () => [ownershipOf(false)], + () => [ownershipOf(true)], + ), + "[binden.revoke]()": call(() => [[{}], null]), + // A failure of a pipe of a work (the work, the error and the source): the + // error is given to the `fail()` of the handlers, and the source kept, to + // be unpiped once the work is revoked + "[binden.pipe_failure]()": probed( + { + readers: { + "fail()": (receiver) => failedWith.get(receiver as object) ?? null, + "[binden.revoke]()": (receiver, _, [owner]) => + Reflect.apply( + Reflect.get(receiver as object, kRevoke) as IFunction, + receiver, + [[owner], null], + ), + }, + untouched: { + 0: { + why: "the work, kept as it is (by identity), with its source", + later: "fail()", + }, + 1: { + why: "the error, given as it is to the `fail()` of the handlers", + later: "fail()", + }, + 2: { + why: "the source, kept as it is, and unpiped once its work is revoked", + later: "[binden.revoke]()", + }, + }, + }, + (_, receiver) => { + Reflect.apply( + Reflect.get(receiver as object, kOwnership) as IFunction, + receiver, + [{ ...ownershipOf(false), fail: failing(receiver) }], + ); + return [{}, new Error("An error"), new EventEmitter()]; + }, + ), + // Returns what removes the observer + "[binden.listen]()": probed( + { + deep: [[2, "once"]], + returned: call(() => []), + callbacks: { 1: "the observer, called as it is" }, + untouched: { + 0: "the event, compared by identity with those that the framework observes", + "2.once": "read by Boolean(), which runs no code of the application", + }, + // Its observer runs as the response closes + readers: { "a close": closedBy }, + io: true, + kept: { + 1: { + why: "the observer, kept, and called as it is", + instead: "callbacks", + }, + 2: { why: "the options of the observer, read once", instead: "unread" }, + }, + }, + () => ["close", noop, { once: true }], + ), + // Of an observer that it has (whose removal its event shows) + "[binden.unlisten]()": probed( + { + readers: { + "emit()": (receiver) => { + (receiver as ServerResponse).emit("custom"); + return observed.get(receiver as object) ?? 0; + }, + }, + untouched: { + 0: "the event, compared by identity: any other value removes nothing", + 1: { + why: "the observer, compared by identity: any other value removes nothing", + later: "emit()", + }, + }, + }, + (_, receiver) => { + const observer = (): void => { + observed.set( + receiver as object, + (observed.get(receiver as object) ?? 0) + 1, + ); + }; + Reflect.apply( + Reflect.get(receiver as object, kListen) as IFunction, + receiver, + ["custom", observer, { once: false }], + ); + return ["custom", observer]; + }, + ), + // Of a send in progress, which rejects with the reason + "[binden.cancel]()": probed( + { + readers: { "the send": settledSend }, + untouched: { 0: { why: cancelled, later: "the send" } }, + }, + (_, receiver) => { + pendingSend(receiver); + return [new Error("A reason")]; + }, + ), + "[binden.cancelling]()": probed( + { + readers: { "the send": settledSend }, + untouched: { 0: { why: cancelled, later: "the send" } }, + }, + (_, receiver) => { + pendingSend(receiver); + return [new Error("A reason")]; + }, + ), + "[binden.framework]()": call(() => [one]), + // Of a library that has replaced the response: the write that it is called + // in is discarded + "[binden.discard_write]()": call(() => []), + // An operation of the current work (with its methods) + "[binden.operation]()": probed( + { + members: { + "run()": call(() => [one]), + "transfer()": call(() => []), + }, + }, + () => [], + ), + // The negotiator is read by `[binden.representation]()`, which calls its + // `choose()`, and by a send whose stream fails before its first chunk, + // which calls its `forget()` + "[binden.negotiator]()": probed( + { + deep: [ + [0, "choose"], + [0, "forget"], + ], + readers: { + "[binden.representation]()": (receiver) => + Reflect.apply( + Reflect.get( + receiver as object, + binden.kRepresentation as symbol, + ) as IFunction, + receiver, + [10], + ), + "send() of a failing stream": (receiver) => failedSend(receiver), + }, + kept: { + 0: { why: "the negotiator, its methods read once", instead: "unread" }, + "0.choose": { + why: "a method of the negotiator, kept, and called as the representation of a body is chosen", + later: "[binden.representation]()", + }, + "0.forget": { + why: "a method of the negotiator, kept, and called once a stream fails before its first chunk", + later: "send() of a failing stream", + ignores: ignoredBy("forget()", "it is called for its effect"), + }, + }, + }, + () => [ + { + choose: (): object => ({ coding: null, status: null }), + forget: noop, + }, + ], + ), + "[binden.representation]()": probed( + { + untouched: { + 0: "the length, any value, given as it is to the negotiator (`choose()`), if any", + }, + }, + () => [10], + ), +}); + +/** + * The destination of the options of a logger (without running code of the + * application): `null` without one (pino writes to the standard output + * then), and `sink` for a proxy of the valid options or a getter (which gives + * the valid one) + */ +function destinationOf(options: unknown): unknown { + let holder: unknown = options; + while (isObject(holder) && !isProxy(holder)) { + const descriptor = Reflect.getOwnPropertyDescriptor(holder, "destination"); + if (typeof descriptor !== "undefined") { + return typeof descriptor.get === "function" + ? sink + : ((descriptor.value as unknown) ?? null); + } + holder = Object.getPrototypeOf(holder); + } + return isProxy(holder) ? sink : null; +} + +/** Options of `pino` that hold others (and a custom level) */ +function pinoOptions(): Record { + return { + level: "custom", + customLevels: { custom: 25 }, + formatters: { level: (label: string): object => ({ label }) }, + redact: { paths: ["secret"], censor: "x" }, + base: { a: 1 }, + }; +} + +// @binden/logger +define("@binden/logger:", { + "new Logger()": probed( + { + // What its records use: its destination, the formatter of its level + // and its custom levels (also of a child) + readers: { + "a record": (_, made, [options]) => { + if (destinationOf(options) === null) { + // pino would write to the standard output + return null; + } + const logger = made as { + info(message: string, extra: object): void; + isLevelEnabled(level: string): boolean; + child(bindings: object): { info(message: string): void }; + }; + logger.info("message", { a: 1, secret: "b" }); + logger.child({ b: 1 }).info("message"); + return logger.isLevelEnabled("custom"); + }, + }, + kept: { + // Of a destination, which pino writes each record to + 0: { + why: "the options, read once, but the destination that they hold", + later: "a record", + }, + "0.destination": { + why: "the destination, kept by pino, which writes each record to it", + later: "a record", + }, + "0.formatters": { why: "the formatters, read once", instead: "unread" }, + // Called once for each level, as the logger is made + "0.formatters.level": { + why: "the formatter of the level, read once", + instead: "unread", + }, + "0.customLevels": { + why: "the custom levels, read once", + instead: "unread", + }, + "0.redact": { why: "the redaction, read once", instead: "unread" }, + "0.redact.paths": { + why: "the paths of the redaction, read once", + instead: "unread", + }, + "0.base": { + why: "the base of the records, read once", + instead: "unread", + }, + }, + deep: [ + [0, "destination"], + [0, "level"], + [0, "error_details"], + // Options of `pino`, also nested ones (which @binden/logger checks, + // e.g. the custom levels of its level, or gives to `pino`) + [0, "name"], + ...[ + "customLevels", + "customLevels.custom", + "formatters", + "formatters.level", + "redact", + "redact.paths", + "redact.paths.0", + "base", + "base.a", + ].map((path) => [0, ...path.split(".")]), + ], + }, + // At the lowest level: every method reads its arguments + () => [ + { destination: sink, level: "trace", error_details: true, name: "name" }, + ], + () => [{ destination: sink, ...pinoOptions() }], + ), + "Logger.fromPino()": probed( + { + readers: { + "a record": (_, made, [given]) => { + if (typeof given === "undefined") { + // A logger of its own, which would write to the standard output + return null; + } + const logger = made as { + info(message: string, extra: object): void; + child(bindings: object): { info(message: string): void }; + }; + logger.info("message", { a: 1 }); + logger.child({ b: 1 }).info("message"); + return null; + }, + }, + kept: { + 0: { + why: "the logger of pino, kept as it is, which writes each record", + later: "a record", + }, + }, + }, + () => [pino({ level: "trace" }, sink)], + ), + "Logger.getLevel()": call( + () => [], + () => ["LOG_LEVEL"], + ), + "Logger#level =": call(() => ["debug"]), + "Logger#isLevelEnabled()": probed( + { + untouched: { + 0: "a predicate of any value (whether it is a level that is enabled)", + }, + }, + () => ["info"], + ), + "Logger#flush()": call(() => []), + "Logger#child()": probed( + { deep: [[1, "level"]] }, + () => [{ a: 1 }], + () => [{ a: 1 }, { level: "debug" }], + ), + "createSerializers()": probed( + { ...serialized, members: loggerSerializing }, + () => [{ error_details: true }], + ), + ...serializersOf(loggerSerializing), +}); +for (const level of ["fatal", "error", "warn", "info", "debug", "trace"]) { + fixtures[`@binden/logger:Logger#${level}()`] = call(() => [ + "message", + { a: 1 }, + ]); +} + +/** + * Why a call does not tell what it keeps of the context of an exchange (or + * its response), which the server of the exchange holds as it handles it, + * and how it is checked instead: it is never read once the call has settled, + * also as the exchange replies (see `replied()`) + */ +function heldContext(how: string): IUntouched { + return { + why: `the context of an exchange, which its server holds as it handles it: ${how}, never once it has settled`, + instead: "settled", + }; +} +/** + * The same, for a parser (that reads the body), also of a request whose body + * is over its limits (`over`), and of one whose body it parses (`within`): + * the uses of the context once the body is read (e.g. the setter of + * `request.body`) are probed too + */ +function parsing(over: IBody, within: IBody): IFixture { + return probed( + // The body over the limits is refused, and what getting the logger of + // the context throws drops the records of the parse only (README) + { + io: true, + throws: [1], + dropsLog: true, + readers: { "the reply": replied }, + held: { 0: heldContext("the parser reads it until the body is parsed") }, + }, + async (env) => [(await env.exchange()).context], + async (env) => [(await env.exchange(over)).context], + async (env) => [(await env.exchange(within)).context], + ); +} +const ContentType = binden.ContentType as new (init: object) => object; + +/** Every option of `BodyParser` */ +function bodyOptions(): Record { + return { + parse: (input: string): unknown => JSON.parse(input), + limit: 1000, + encoded_limit: 2000, + type: ["application/json", "text/plain"], + raw: false, + strict: true, + parameter_limit: 10, + verify: noop, + verify_encoded: false, + zstd: false, + disabled: false, + ignore_errors: false, + }; +} + +/** Every option of `Compression` */ +function compressionOptions(): Record { + return { + format: "auto", + encodings: ["gzip", "br", "deflate", "zstd"], + threshold: 0, + filter: (): boolean => true, + gzip: { level: 1 }, + deflate: { level: 1 }, + brotli: { params: {} }, + zstd: { params: {} }, + disabled: false, + ignore_errors: false, + }; +} + +/** Every option of `Multipart` (but its `storage`) */ +function multipartOptions(): Record { + return { + config: { limits: { files: 1 } }, + throw_limits: false, + limit: 1000, + file_filter: (): boolean => true, + filtered: "skip", + storage: "memory", + disabled: false, + ignore_errors: false, + }; +} + +/** The fixture of the options of `Multipart` (also of its default export) */ +const multipart = probed( + { + deep: keysAt(0, multipartOptions(), [ + "config.limits", + "config.limits.files", + "storage.dir", + "storage.on_file", + ]), + // A body of a file that it parses (whose part its `file_filter` accepts, + // and its `on_file` gets) + io: true, + readers: { "a file part": (_, made) => parsedBy(made, "small-multipart") }, + wraps: ["a file part"], + kept: { + "0.file_filter": { + why: "the filter of the files, kept, and called for each file part", + later: "a file part", + }, + "0.storage.on_file": { + why: "the handler of the files, kept, and called for each file part", + later: "a file part", + ignores: ignoredBy("on_file", "README of @binden/multipart, Storage"), + }, + }, + }, + () => [], + () => [multipartOptions()], + () => [{ ...multipartOptions(), storage: { dir: tmpdir() } }], + () => [{ ...multipartOptions(), storage: { on_file: noop } }], +); + +/** A method that @binden/compression installs on a response */ +function compressing(options: IFixtureOptions, ...calls: ICall[]): IFixture { + return probed({ ...options, io: true, states: true }, ...calls); +} + +// @binden/body, @binden/compression and @binden/multipart +define("@binden/body:", { + "new BodyParser()": probed( + { + deep: keysAt(0, bodyOptions(), ["type.0"]), + // A JSON body that it parses (by its `parse`, of a type that its + // `type` selects, once its `verify` has checked it) + io: true, + readers: { "a parsed body": (_, made) => parsedBy(made, "small-json") }, + wraps: ["a parsed body"], + kept: { + "0.parse": { + why: "the parser of the JSON bodies, kept, and called as one is parsed", + later: "a parsed body", + }, + "0.type": { + why: "what selects the kind of a body, kept, and called as one is parsed", + later: "a parsed body", + }, + "0.verify": { + why: "the check of a body, kept, and called as one is parsed", + later: "a parsed body", + ignores: ignoredBy("verify", "README of @binden/body, Verification"), + }, + }, + }, + // The instance of the prototype members: one of small limits + () => [{ limit: 4, encoded_limit: 4 }], + () => [], + () => [bodyOptions()], + () => [{ ...bodyOptions(), type: (): string => "text" }], + ), + "BodyParser#run()": parsing("text", "small-text"), +}); +define("@binden/compression:", { + "new Compression()": probed( + { + deep: keysAt(0, compressionOptions(), [ + "encodings.0", + "encodings.2", + "encodings.3", + "gzip.level", + "deflate.level", + "brotli.params", + "zstd.params", + ]), + // A response that it compresses (by `gzip`, or `zstd`), whose type its + // `filter` decides: a filter that fails replaces it, and never fails + // the send + io: true, + readers: { + "a compressed response": (_, made) => compressedBy(made), + "a zstd response": (_, made) => compressedBy(made, "zstd"), + }, + infallible: { + "a compressed response": + "README of @binden/compression, filter: a filter that fails replaces the response by a 500", + }, + kept: { + "0.filter": { + why: "the filter, kept, and called as the representation of a response is chosen", + later: "a compressed response", + }, + }, + }, + // The instance of the prototype members: one that codes any body + () => [{ threshold: 0 }], + () => [], + () => [compressionOptions()], + ), + // Installs its own methods on the response + "Compression#run()": probed( + { + io: true, + installed: { + "0.response.writeHead": compressing( + { + deep: [ + [1, "x-a"], + [2, "x-a"], + ], + converts: { + 0: nodeCoercion, + "1.x-a": nodeCoercion, + "2.x-a": nodeCoercion, + }, + node: plainly("writeHead"), + untouched: { 1: reasonUntouched }, + }, + ...heads.slice(0, 3), + ), + "0.response.write": compressing( + { callbacks: written, untouched: { 2: ignoredCallback } }, + () => ["text"], + () => ["text", noop], + () => ["74657874", "hex", noop], + ), + "0.response.end": compressing( + { + callbacks: { + 0: "the callback, which Node.js calls as it is", + ...written, + }, + untouched: { 2: ignoredCallback }, + }, + () => [], + () => [noop], + () => ["text", noop], + () => ["74657874", "hex", noop], + ), + "0.response.flush": compressing({}, () => []), + "0.response.writableEnded": compressing({}, () => []), + }, + readers: { "the reply": replied }, + held: { + 0: { + why: "the context of an exchange, which its server holds as it handles it: its writers read it as the response replies", + later: "the reply", + }, + }, + }, + async (env) => [(await env.exchange()).context], + ), + "isCompressible()": call( + () => [null], + () => [new ContentType({ type: "text/plain" })], + ), + "skipCompression()": probed( + { + readers: { "the reply": replied }, + held: { + 0: { + why: "the response of an exchange, which its server holds as it handles it: marked to be sent as it is, never read once the call has returned", + instead: "settled", + }, + }, + }, + async (env) => [(await env.exchange()).response], + ), +}); +define("@binden/multipart:", { + "new Multipart()": { + ...multipart, + // The instance of the prototype members: one of a small file size + calls: [ + (): readonly unknown[] => [{ config: { limits: { fileSize: 2 } } }], + ...multipart.calls, + ], + }, + "Multipart#run()": parsing("multipart", "small-multipart"), + // The name and the options are read by the constructor of `File` of + // Node.js, which converts its name, `type` and `lastModified` + "new MultipartFile()": probed( + { + deep: [ + [2, "type"], + [2, "truncated"], + [2, "lastModified"], + ], + converts: { + 1: fileCoercion, + "2.type": fileCoercion, + "2.lastModified": fileCoercion, + }, + node: (values): File => + new File(...(values as ConstructorParameters)), + }, + () => [["a"], "a.txt"], + () => [ + ["a"], + "a.txt", + { type: "text/plain", truncated: false, lastModified: 0 }, + ], + ), + "default()": multipart, + "multipartInfo()": probed( + { + readers: { "the reply": replied }, + held: { 0: heldContext("its request is read by the call") }, + }, + async (env) => [(await env.exchange()).context], + ), +}); + +/** + * A reader of what an entry point stores (see `readers`): given its receiver, + * what the call has returned, and the arguments that it was given + */ +type IReader = ( + receiver: unknown, + result: unknown, + values: readonly unknown[], +) => unknown; + +/** An entry point of the surface */ +interface IEntry { + readonly id: string; + readonly kind: "construct" | "get" | "method" | "set"; + /** The function: the class, the method, the getter or the setter */ + readonly fn: IFunction; + /** + * Its receivers, the first being the default one: none for a constructor + * or a function of a package + */ + readonly receivers: readonly IReceiver[] | null; + /** + * The readers of what it stores, by their names: the getter of a setter, + * and the getters (and `toString()`) of what a constructor makes + */ + readonly readers: Readonly>; +} + +/** The name of a key, as the ids show it */ +function keyName(key: PropertyKey): string { + return typeof key === "symbol" ? `[${String(key.description)}]` : String(key); +} + +/** + * The getters (and `toString()`) of the prototypes of the packages, from a + * prototype up to one of Node.js or of the language: the readers of the + * instances that a constructor makes + */ +function readersOf(prototype: unknown): Record { + const found: Record = {}; + let current = prototype; + while (isObject(current) && !foreign.has(current)) { + for (const key of Reflect.ownKeys(current)) { + const descriptor = Reflect.getOwnPropertyDescriptor(current, key); + const get: unknown = Reflect.get(descriptor ?? {}, "get"); + const value: unknown = Reflect.get(descriptor ?? {}, "value"); + const name = keyName(key); + if (typeof get === "function" && !Object.hasOwn(found, name)) { + found[name] = (_, result): unknown => Reflect.apply(get, result, []); + } else if ( + key === "toString" && + typeof value === "function" && + !Object.hasOwn(found, "toString()") + ) { + found["toString()"] = (_, result): unknown => + Reflect.apply(value as IFunction, result, []); + } + } + current = Object.getPrototypeOf(current); + } + return found; +} + +/** The entry points of the members of an object (a class, a prototype…) */ +function members( + id: string, + object: object, + separator: string, + receiversOf: (kind: IEntry["kind"]) => readonly IReceiver[], + skipped: readonly PropertyKey[], +): IEntry[] { + const found: IEntry[] = []; + for (const key of Reflect.ownKeys(object)) { + if (!skipped.includes(key)) { + const descriptor = Reflect.getOwnPropertyDescriptor( + object, + key, + ) as PropertyDescriptor; + const name = `${id}${separator}${keyName(key)}`; + for (const [field, kind, suffix] of [ + ["get", "get", ""], + ["set", "set", " ="], + ["value", "method", "()"], + ] as const) { + const fn: unknown = Reflect.get(descriptor, field); + if (typeof fn === "function") { + const get: unknown = Reflect.get(descriptor, "get"); + found.push({ + id: `${name}${suffix}`, + kind, + fn: fn as IFunction, + receivers: receiversOf(kind), + // A setter is read by its getter + readers: + kind === "set" && typeof get === "function" + ? { + [keyName(key)]: (receiver): unknown => + Reflect.apply(get, receiver, []), + } + : {}, + }); + } + } + } + } + return found; +} + +/** + * The exports that are instances of an exported class (see `instances`), + * by their ids + */ +const exportedInstances = new Set(); + +/** Every entry point of the surface, and the ids of the classes */ +function surface(): [IEntry[], Set] { + const found: IEntry[] = []; + const classes = new Set(); + for (const [name, namespace] of entries) { + for (const key of Reflect.ownKeys(namespace)) { + const value = namespace[key]; + const id = `${name}:${keyName(key)}`; + if (isClass(value)) { + classes.add(id); + const constructor = `${name}:new ${keyName(key)}()`; + found.push({ + id: constructor, + kind: "construct", + fn: value, + receivers: null, + readers: readersOf(value.prototype), + }); + found.push( + ...members(id, value, ".", () => [fresh(() => value)], [ + "length", + "name", + "prototype", + ]), + ); + // An instance of a valid call of the constructor + const instance = (overload: number): IReceiver => ({ + name: overload === 0 ? "" : `instance ${String(overload)}`, + state: "fresh", + async make(env: IEnv): Promise { + if (!Object.hasOwn(fixtures, constructor)) { + throw new TypeError("No instance without a constructor"); + } + const made = fixtures[constructor].calls[overload]; + return Reflect.construct(value, await made(env, null)); + }, + }); + const overloads = Object.hasOwn(fixtures, constructor) + ? fixtures[constructor].calls.length + : 1; + found.push( + ...members( + id, + value.prototype as object, + "#", + (kind) => { + if (Object.hasOwn(receivers, id)) { + return receivers[id]; + } + // A getter is read on an instance of each valid call + return kind === "get" + ? Array.from({ length: overloads }, (_, index) => + instance(index), + ) + : [instance(0)]; + }, + ["constructor"], + ), + ); + } else if (typeof value === "function") { + found.push({ + id: `${id}()`, + kind: "method", + fn: value as IFunction, + receivers: null, + readers: {}, + }); + } else if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + if (exported.has(Object.getPrototypeOf(value))) { + exportedInstances.add(id); + } else { + found.push( + ...members(id, value, ".", () => [fresh(() => value)], []), + ); + } + } + } + } + return [found, classes]; +} + +/** What a call has done */ +type IOutcome = + | { readonly kind: "pending" } + | { readonly kind: "rejected"; readonly error: unknown } + | { readonly kind: "resolved"; readonly value: unknown } + | { readonly kind: "returned"; readonly value: unknown } + | IThrown; +/** A call that has thrown */ +interface IThrown { + readonly kind: "thrown"; + readonly error: unknown; +} + +/** Calls a function: what it returns, or throws */ +function start( + fn: () => unknown, +): { readonly kind: "started"; readonly result: unknown } | IThrown { + try { + return { kind: "started", result: fn() }; + } catch (error: unknown) { + return { kind: "thrown", error }; + } +} + +/** + * Runs a function once: what it returns, throws or settles with (bounded), + * calling `returned` once its synchronous part has returned (or thrown) + */ +async function outcomeOf( + fn: () => unknown, + ms: number, + returned: () => void = noop, +): Promise { + const started = start(fn); + returned(); + if (started.kind === "thrown") { + return started; + } + const { result } = started; + if (!isPromise(result)) { + return { kind: "returned", value: result }; + } + const promise = result; + const { promise: expired, resolve } = Promise.withResolvers(); + const timeout = setTimeout(() => { + resolve({ kind: "pending" }); + }, ms); + try { + return await Promise.race([ + promise.then( + (value: unknown): IOutcome => ({ kind: "resolved", value }), + (error: unknown): IOutcome => ({ kind: "rejected", error }), + ), + expired, + ]); + } finally { + clearTimeout(timeout); + } +} + +/** Calls an entry point (constructs a class) */ +function called( + entry: IEntry, + receiver: unknown, + values: readonly unknown[], +): unknown { + return entry.kind === "construct" + ? Reflect.construct(entry.fn, values) + : Reflect.apply(entry.fn, receiver, values); +} + +/** The late failures of the process (an uncaught exception or rejection) */ +const late: { readonly how: string; readonly error: unknown }[] = []; +process.on("uncaughtException", (error) => { + late.push({ how: "uncaught", error }); +}); +process.on("unhandledRejection", (error) => { + late.push({ how: "unhandled rejection", error }); +}); + +/** The value at a path of the arguments (`undefined` when there is none) */ +function valueAt( + values: readonly unknown[], + path: readonly (number | string)[], +): unknown { + let value: unknown = values; + for (const key of path) { + value = isObject(value) ? Reflect.get(value, key) : null; + } + return value; +} + +/** Whether a value is a plain object (of `Object.prototype`, or of none) */ +function isPlain(value: unknown): value is object { + return ( + typeof value === "object" && + value !== null && + [Object.prototype, null].includes(Object.getPrototypeOf(value) as object) + ); +} + +/** + * Replaces the value at a path of the arguments (copying what holds it) by + * a hostile value (or places it, see `place`): `null` when the path does + * not exist. What holds it is an array, a plain object, or an instance (e.g. + * a logger of binden), which is copied as an object derived from it (see + * `derived`) whose own property shadows the one that it inherits. + */ +function replaced( + values: readonly unknown[], + path: readonly (number | string)[], + given: Pick | null, +): unknown[] | null { + const copy = ( + holder: unknown, + rest: readonly (number | string)[], + ): unknown => { + const [head = "", ...tail] = rest; + const missing = Array.isArray(holder) + ? !Number.isInteger(Number(head)) || Number(head) >= holder.length + : typeof holder !== "object" || + holder === null || + (isPlain(holder) ? !Object.hasOwn(holder, head) : !(head in holder)); + if (missing) { + throw new RangeError("missing"); + } + const object: object = Array.isArray(holder) + ? [...(holder as unknown[])] + : isPlain(holder) + ? { ...holder } + : (Object.create(holder as object) as object); + const inner: unknown = Reflect.get(object, head); + const own = (value: unknown): PropertyDescriptor => ({ + configurable: true, + enumerable: true, + writable: true, + value, + }); + if (tail.length > 0) { + Object.defineProperty(object, head, own(copy(inner, tail))); + } else if (typeof given?.place === "function") { + Object.defineProperty(object, head, given.place(inner)); + } else { + Object.defineProperty(object, head, own(given?.value)); + } + return object; + }; + try { + return copy(values, path) as unknown[]; + } catch { + return null; + } +} + +/** Whether a path of the arguments exists */ +function exists( + values: readonly unknown[], + path: readonly (number | string)[], +): boolean { + return replaced(values, path, null) !== null; +} + +/** + * Copies the holders (plain objects and arrays) of the nested positions of + * the arguments, each property a getter that counts its reads (by its path) + */ +function counted( + values: readonly unknown[], + paths: readonly (readonly (number | string)[])[], + reads: Map, +): unknown[] { + /** The paths of the holders (e.g. `0` and `0.headers`) */ + const holders = new Set(); + for (const path of paths) { + for (let length = 1; length < path.length; length += 1) { + holders.add(path.slice(0, length).join(".")); + } + } + const wrap = (value: unknown, at: string): unknown => { + const plain = + Array.isArray(value) || + (typeof value === "object" && + value !== null && + Object.getPrototypeOf(value) === Object.prototype); + if (!holders.has(at) || !plain) { + return value; + } + const copy: object = Array.isArray(value) ? [] : {}; + for (const key of Object.keys(value)) { + const where = `${at}.${key}`; + const inner = wrap(Reflect.get(value, key), where); + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + get(): unknown { + reads.set(where, (reads.get(where) ?? 0) + 1); + return inner; + }, + }); + } + return copy; + }; + return values.map((value, index) => wrap(value, String(index))); +} + +/** + * Cleans up the environment of a probe (bounded), then waits for two turns + * of the event loop: a late failure of the probe is reported by then + */ +async function clean(env: IEnv): Promise { + const pending: unknown[] = []; + for (const cleanup of env.cleanups.reverse()) { + try { + pending.push(cleanup()); + } catch { + // E.g. a server that is not listening + } + } + const { promise: expired, resolve } = Promise.withResolvers(); + const timeout = setTimeout(() => { + resolve(true); + }, cleanMs); + const expiredClean = await Promise.race([ + Promise.allSettled(pending).then(() => false), + expired, + ]); + clearTimeout(timeout); + await turn(); + await turn(); + return expiredClean + ? [`its cleanup has not settled in ${String(cleanMs)} ms`] + : []; +} + +/** A finding: what an entry point has done wrong, in which probe */ +interface IFinding { + readonly entry: string; + /** `baseline`, or the position and the hostile value (e.g. `0.key null`) */ + readonly probe: string; + readonly what: string; +} + +/** + * What a thrown (or rejected) value shows: an `Error` must be formatted (a + * baseline must throw an `Error`), and a hostile value whose trap has + * thrown in that call (or that read, the traps after the first `since`) + * must get the error of the test + */ +function judged( + how: string, + error: unknown, + given: IHostile | null, + unexpected: boolean, + baseline: boolean, + since = 0, +): string[] { + const isError = !isProxy(error) && isNativeError(error); + const isFormatted = isError && formatted.has(error); + const own = !isProxy(error) && error instanceof OwnError; + const found: string[] = []; + if (unexpected) { + found.push(`${how} ${describe(error)}`); + } else if (!isFormatted && (isError || baseline)) { + found.push(`${how} UNFORMATTED ${describe(error)}`); + } + if (given !== null) { + if (own && !given.thrown.includes(error)) { + found.push(`${how} another error of the test`); + } else if (given.thrown.length > since && !own) { + found.push(`${how} ${describe(error)}, not the error of the test`); + } + } + return found; +} + +/** Evaluates what a probe has done (see `probe()`) */ +function evaluate( + fixture: IFixture, + must: boolean, + may: boolean, + path: readonly (number | string)[] | null, + given: IHostile | null, + outcome: IOutcome, + ms: number, +): string[] { + switch (outcome.kind) { + case "pending": + return [`still pending after ${String(ms)} ms`]; + case "returned": + case "resolved": + if (path === null && must) { + return ["does not throw"]; + } + if ( + path === null && + outcome.kind === "returned" && + typeof outcome.value === "function" && + !isClass(outcome.value) && + typeof fixture.returned === "undefined" + ) { + return ["returns a function that has no fixture (`returned`)"]; + } + return []; + case "thrown": + case "rejected": + default: { + const how = outcome.kind === "thrown" ? "throws" : "rejects with"; + // A `log` getter whose failure the call drops (see `dropsLog`): it + // settles as without the records, never with the error of the test + const drops = + fixture.dropsLog === true && + given !== null && + contextHostiles.has(given.name); + return [ + ...judged( + how, + outcome.error, + drops ? null : given, + path === null && !may, + path === null, + ), + ...(drops && + !isProxy(outcome.error) && + outcome.error instanceof OwnError + ? [ + `${how} the error of its \`log\` getter, which it drops (\`dropsLog\`)`, + ] + : []), + ]; + } + } +} + +/** + * The positions whose hostile value has been converted (as ` + * `): each documented conversion (see `converts`) must be one, so + * that the list names only conversions that happen + */ +const converting = new Set(); +/** + * The callback positions (see `callbacks`, as ` `) where a + * function of the test has been called later + */ +const calledBack = new Set(); +/** + * The entry points whose calls a `log` getter of the test has failed, which + * they drop (see `dropsLog`) + */ +const droppedLog = new Set(); + +const Context = binden.Context as IFunction; + +/** The own keys of the functions and the accessors of an object */ +function functionKeys(object: object): ReadonlySet { + return new Set( + Reflect.ownKeys(object).filter((key) => { + const descriptor = Reflect.getOwnPropertyDescriptor( + object, + key, + ) as PropertyDescriptor; + return ( + typeof descriptor.value === "function" || + typeof descriptor.get === "function" || + typeof descriptor.set === "function" + ); + }), + ); +} + +/** + * The objects that a call may install functions on, by their names: its + * receiver (`this`), each argument (by its index), and the request and the + * response of a `Context` given (e.g. `0.response`), each with the own keys + * of its functions and accessors + */ +function owned( + receiver: unknown, + values: readonly unknown[], +): Map]> { + const found = new Map]>(); + const add = (name: string, value: unknown): void => { + if (typeof value === "object" && value !== null && !isProxy(value)) { + found.set(name, [value, functionKeys(value)]); + } + }; + add("this", receiver); + for (const [index, value] of values.entries()) { + add(String(index), value); + if (!isProxy(value) && value instanceof Context) { + add(`${String(index)}.request`, Reflect.get(value, "request")); + add(`${String(index)}.response`, Reflect.get(value, "response")); + } + } + return found; +} + +/** + * The functions and accessors installed since `owned()` (e.g. + * `0.response.write`) + */ +function installedSince( + before: ReadonlyMap]>, +): string[] { + return [...before].flatMap(([name, [object, keys]]) => + [...functionKeys(object)] + .filter((key) => !keys.has(key)) + .map((key) => `${name}.${keyName(key)}`), + ); +} + +/** A member of an object: its kind, its key, and whether it is a class */ +type IMember = readonly [IEntry["kind"], PropertyKey, boolean]; + +/** + * The members (functions and accessors) of an object of a package that a + * call returns, by their names (e.g. `add()` or `active`): its own ones, and + * those of its prototypes up to one of Node.js or of the language (none for + * an instance of an exported class, whose members are probed, nor for an + * array) + */ +function membersOf(value: unknown): Map { + const found = new Map(); + if ( + typeof value !== "object" || + value === null || + isProxy(value) || + Array.isArray(value) || + isNativeError(value) + ) { + return found; + } + const chain: object[] = [value]; + let prototype: unknown = Object.getPrototypeOf(value); + while (!foreign.has(prototype)) { + if (exported.has(prototype)) { + return new Map(); + } + chain.push(prototype as object); + prototype = Object.getPrototypeOf(prototype); + } + /** The own keys of a plain instance of the same class of Node.js */ + let plain: ReadonlySet = new Set(); + if ( + chain.length === 1 && + prototype !== Object.prototype && + prototype !== null + ) { + if (!(value instanceof NetServer)) { + // An instance of Node.js (or of the language), whose members are its own + return found; + } + // A server (e.g. of `createServer()`): what binden installs on it + plain = new Set( + Reflect.ownKeys( + Reflect.construct(value.constructor as IFunction, []) as object, + ), + ); + } + for (const holder of chain) { + for (const key of Reflect.ownKeys(holder).filter( + (own) => !plain.has(own), + )) { + const descriptor = Reflect.getOwnPropertyDescriptor( + holder, + key, + ) as PropertyDescriptor; + for (const [field, kind, suffix] of [ + ["get", "get", ""], + ["set", "set", " ="], + ["value", "method", "()"], + ] as const) { + const fn: unknown = Reflect.get(descriptor, field); + const name = `${keyName(key)}${suffix}`; + if ( + typeof fn === "function" && + (holder === value || key !== "constructor") && + !found.has(name) + ) { + found.set(name, [kind, key, field === "value" && isClass(fn)]); + } + } + } + } + return found; +} + +/** What a probe has found, and whether its hostile value was examined */ +interface IProbed { + readonly findings: IFinding[]; + readonly examined: boolean; + /** The kind of the error that it throws (or rejects with), if any */ + readonly failure: string | null; + /** Whether the call has accepted its hostile value (it has succeeded) */ + readonly accepted: boolean; +} + +/** The kind of an error (its class), without running code of the application */ +function kindOf(error: unknown): string { + return !isProxy(error) && isNativeError(error) + ? error.constructor.name + : `(${typeof error})`; +} + +/** + * Whether an error is a `BindenError` (formatted, see `judged()`) of an error + * of the test, its `cause` (see `wraps`) + */ +function wrapsOwn(error: unknown, given: IHostile): boolean { + return ( + !isProxy(error) && + error instanceof (binden.BindenError as IFunction) && + given.thrown.includes(Reflect.get(error, "cause") as OwnError) + ); +} + +/** Whether a late failure is the call of a function of the test (see `callbacks`) */ +function calledOut(error: unknown, given: IHostile): boolean { + return ( + (!isProxy(error) && + error instanceof OwnError && + given.thrown.includes(error)) || + (!isProxy(error) && + isNativeError(error) && + error.name === "TypeError" && + /^(?:Cannot perform 'apply' on a proxy that has been revoked|Class constructor \w+ cannot be invoked without 'new')$/u.test( + error.message, + )) + ); +} + +/** + * The readers that have run with a hostile value at a position (as ` + * `), on the default receiver: what `IUntouched.later` + * needs + */ +const readLater = new Set(); +/** + * The readers that have used a hostile value at a position (as ` + * `): one of its traps or conversions has run as they + * read, or they have had another outcome than with the valid value (see + * `signature()`) + */ +const usedLater = new Set(); +/** + * The same, by the hostile value used (as ` + * `): what `IKept.ignores` needs + */ +const usedLaterBy = new Set(); +/** + * The positions (as ` `) where readers have run with the + * `late-use` hostile, and those where one of its traps has run once the + * call had returned (read later): what `instead: "unread"` needs + */ +const unreadRun = new Set(); +const readLate = new Set(); +/** + * The same, for `instead: "settled"`: the positions where readers have run + * with `late-use` once the call has settled, and those where one of its + * traps has run as they read + */ +const settledRun = new Set(); +const readSettled = new Set(); +/** + * The readers of `logs` that have run a method that the application has + * replaced on a logger of binden (as ` `): each one listed + * must have + */ +const loggedBy = new Set(); +/** + * The alternative checks that have run (as ` `, see + * `alternativesOf()`): what `IUntouched.instead` needs + */ +const checkedInstead = new Set(); +/** + * The outcomes of the readers with the valid values (as ` + * `, see `signature()`), on the default receiver + */ +const baselineReads = new Map(); + +/** + * What an outcome of a reader shows, to compare it with the one with the + * valid value: its kind, the kind of its error, and what it gives (the + * hostile object given itself, a primitive, or the type of an object) + */ +function signature(outcome: IOutcome, given: IHostile | null): string { + switch (outcome.kind) { + case "thrown": + case "rejected": + return `${outcome.kind} ${kindOf(outcome.error)}`; + case "returned": + case "resolved": { + const { value } = outcome; + // Only an object is told by its identity: a reader that gives a + // primitive (e.g. `undefined`) gives it whatever it is given + if (given !== null && isObject(given.value) && value === given.value) { + return `${outcome.kind} the value given`; + } + return isObject(value) + ? `${outcome.kind} ${isProxy(value) ? "a proxy" : typeof value}` + : `${outcome.kind} ${typeof value} ${String(value)}`; + } + case "pending": + default: + return outcome.kind; + } +} + +/** + * What a call (`call`) or a reader that never fails (see `infallible`) has + * done wrong: thrown or rejected + */ +function infallibly( + fixture: IFixture, + what: string, + outcome: IOutcome, +): string[] { + const documented = fixture.infallible?.[what]; + if ( + typeof documented === "undefined" || + (outcome.kind !== "thrown" && outcome.kind !== "rejected") + ) { + return []; + } + return [ + `${what === "call" ? "the call" : `its reader ${what}`} fails (${describe(outcome.error)}), but never does (${documented})`, + ]; +} + +/** + * How often a call converts the `statusCode` of its receiver, an object (see + * `coerced`), as it commits the head: as often as the same call on plain + * Node.js (see `node`), and at most once without it (as Node.js converts it + * once, as it commits the head) + */ +async function statusCounts( + fixture: IFixture, + conversions: number, + values: () => Promise, + env: IEnv, + ms: number, +): Promise { + const { node } = fixture; + if (typeof node === "undefined") { + return conversions > 1 + ? [ + `converts an object statusCode ${String(conversions)}x, Node.js once at most`, + ] + : []; + } + const [status, byNode] = statusObject(); + const again = await values(); + await outcomeOf( + () => + node(again, { + env, + response(): ServerResponse { + const response = plainResponse("fresh"); + response.statusCode = status as unknown as number; + return response; + }, + }), + ms, + ); + return conversions === byNode() + ? [] + : [ + `converts an object statusCode ${String(conversions)}x, plain Node.js ${String(byNode())}x`, + ]; +} + +/** Runs a probe on a fresh environment, and cleans it up */ +async function probe( + entry: IEntry, + fixture: IFixture, + receiving: IReceiver | null, + index: number, + path: readonly (number | string)[] | null, + name: IHostileName | null, + expected: string | null = null, +): Promise { + const env = environment(); + const isDefault = receiving === null || receiving.name === ""; + const state = receiving?.state ?? "fresh"; + const position = path?.join(".") ?? ""; + const at = `${isDefault ? "" : `${receiving.name}: `}${ + path === null ? "baseline" : `${position} ${name ?? ""}` + }`; + const found: string[] = []; + const made = fixture.calls[index]; + const { throws = false } = fixture; + const throwing = + typeof throws === "boolean" ? throws : throws.includes(index); + const ms = fixture.io === true ? ioMs : pendingMs; + let given: IHostile | null = null; + let examined = true; + let failure: string | null = null; + let accepted = false; + /** The runs of replaced methods of a logger by readers of the application (see `logs`) */ + let misusedByApplication = 0; + try { + const receiver = + receiving === null + ? null + : await bounded(receiving.make(env), makeMs, "Its receiver"); + const baseline = + entry.kind === "get" + ? [] + : await bounded(made(env, receiver), makeMs, "Its arguments"); + given = + path === null || name === null + ? null + : hostile(name, valueAt(baseline, path)); + const values = path === null ? baseline : replaced(baseline, path, given); + if (values === null) { + found.push("the position does not exist"); + } else { + const before = path === null ? owned(receiver, values) : null; + const outcome = await outcomeOf( + () => called(entry, receiver, values), + ms, + () => given?.arm?.(), + ); + // What the call itself has examined (see `examined`), not its + // readers (see `IUntouched`) + const conversions = given?.conversions() ?? 0; + const trapped = given?.thrown.length ?? 0; + failure = + outcome.kind === "thrown" || outcome.kind === "rejected" + ? kindOf(outcome.error) + : null; + if (before !== null && isDefault) { + for (const installed of installedSince(before)) { + if (!Object.hasOwn(fixture.installed ?? {}, installed)) { + found.push( + `installs ${installed}, which has no fixture (\`installed\`)`, + ); + } + } + } + if (path === null && isDefault && outcome.kind === "returned") { + for (const [member, [kind]] of membersOf(outcome.value)) { + if (kind !== "get" && !Object.hasOwn(fixture.members ?? {}, member)) { + found.push( + `returns an object whose ${member} has no fixture (\`members\`)`, + ); + } + } + } + const refused = + outcome.kind === "returned" && (fixture.refused?.(values) ?? false); + if (path === null && refused) { + found.push("refuses its valid arguments (`refused`)"); + } else if ( + path !== null && + !refused && + (fixture.refuses ?? []).includes(position) + ) { + found.push( + `is not refused as documented (\`refuses\`), but ${outcome.kind === "thrown" || outcome.kind === "rejected" ? `${outcome.kind} ${describe(outcome.error)}` : outcome.kind}`, + ); + } + found.push( + ...evaluate( + fixture, + throwing && isDefault, + throwing || !isDefault, + path, + given, + outcome, + ms, + ), + ...infallibly(fixture, "call", outcome), + ); + if ( + fixture.dropsLog === true && + given !== null && + contextHostiles.has(given.name) && + trapped > 0 + ) { + droppedLog.add(entry.id); + } + const succeeded = + outcome.kind === "returned" || outcome.kind === "resolved"; + if ( + state === "detaching" && + path === null && + succeeded && + (fixture.loses ?? []).includes(index) + ) { + found.push( + "resolves, although a writer of the application has lost its bytes (`loses`)", + ); + } + const statusConverted = + path === null ? statusConversions.get(receiver as object) : null; + if (typeof statusConverted === "function") { + found.push( + ...(await statusCounts( + fixture, + statusConverted(), + () => bounded(made(env, receiver), makeMs, "Its arguments"), + env, + ms, + )), + ); + } + if (succeeded) { + const result = outcome.kind === "returned" ? outcome.value : null; + const readers: Record = { + ...entry.readers, + ...fixture.readers, + }; + for (const [reader, read] of Object.entries(readers)) { + // A reader is judged by the traps that run as it reads (e.g. not + // by those of a reader before it) + const since = given?.thrown.length ?? 0; + const convertedSince = given?.conversions() ?? 0; + const misusedSince = given?.misused?.() ?? 0; + const readOutcome = await outcomeOf( + () => read(receiver, result, values), + ms, + ); + if ((fixture.logs ?? []).includes(reader)) { + const runs = (given?.misused?.() ?? 0) - misusedSince; + misusedByApplication += runs; + if (runs > 0) { + loggedBy.add(`${entry.id} ${reader}`); + } + } + if (isDefault) { + const shown = signature(readOutcome, given); + const baselineKey = `${entry.id} ${String(index)} ${reader}`; + if (given === null) { + baselineReads.set(baselineKey, shown); + } else { + const key = `${entry.id} ${position} ${reader}`; + readLater.add(key); + if ( + given.thrown.length > since || + given.conversions() > convertedSince || + shown !== baselineReads.get(baselineKey) + ) { + usedLater.add(key); + usedLaterBy.add(`${key} ${given.name}`); + } + } + } + found.push(...infallibly(fixture, reader, readOutcome)); + if (readOutcome.kind === "pending") { + found.push( + `its reader ${reader} is still pending after ${String(ms)} ms`, + ); + } else if ( + readOutcome.kind === "thrown" || + readOutcome.kind === "rejected" + ) { + const how = `its reader ${reader} ${readOutcome.kind === "thrown" ? "throws" : "rejects with"}`; + const { error } = readOutcome; + const wraps = + given !== null && (fixture.wraps ?? []).includes(reader); + if ( + wraps && + !isProxy(error) && + given?.thrown.includes(error as OwnError) === true + ) { + found.push( + `${how} the error of the test, not a BindenError of it (\`wraps\`)`, + ); + } + found.push( + ...judged( + how, + error, + given !== null && wraps && wrapsOwn(error, given) + ? null + : given, + false, + false, + since, + ), + ); + } + } + if (isDefault && given?.name === "late-use") { + const key = `${entry.id} ${position}`; + if (Object.keys(readers).length > 0) { + unreadRun.add(key); + settledRun.add(key); + } + if (given.thrown.length > 0) { + readLate.add(key); + } + if (given.thrown.length > trapped) { + readSettled.add(key); + } + } + } + accepted = succeeded && !refused; + if (given !== null) { + const total = given.conversions(); + const allowed = fixture.converts?.[position]; + // A call that throws by design examines a value that makes it + // throw something else + examined = + (!succeeded && failure !== expected) || + refused || + conversions > 0 || + trapped > 0; + if (total > 0) { + converting.add(`${entry.id} ${position}`); + } + if (typeof allowed === "undefined") { + if (total > 0) { + found.push( + `converted ${String(total)}x${succeeded ? "" : ` before it ${outcome.kind === "thrown" ? "throws" : "rejects"}`}`, + ); + } + } else { + if (allowed === cookieValue) { + if (conversions > 0) { + found.push( + `converted ${String(conversions)}x by the call, documented as the head is committed`, + ); + } + if (total - conversions > 1) { + found.push( + `converted ${String(total - conversions)}x by its readers, documented once`, + ); + } + } else if (total > conversions) { + found.push( + `converted ${String(total - conversions)}x by its readers`, + ); + } + if (allowed === headerInit && conversions > 1) { + found.push(`converted ${String(conversions)}x, documented once`); + } else if ( + allowed === headerInit && + name === "counting" && + isDefault && + succeeded && + conversions === 0 + ) { + found.push("not converted, documented once"); + } + if ( + name === "counting" && + (allowed === nodeCoercion || + allowed === fileCoercion || + allowed === nodeCheck) + ) { + if (typeof fixture.node === "undefined") { + found.push( + "a coercion of Node.js without the same call on plain Node.js (`node`)", + ); + } else { + const reference = hostile("counting", null); + const again = replaced( + await bounded(made(env, receiver), makeMs, "Its arguments"), + path as readonly (number | string)[], + reference, + ); + const { node } = fixture; + await outcomeOf( + () => + node(again ?? [], { + env, + response: () => plainResponse(state), + }), + ms, + ); + const byNode = reference.conversions(); + if ( + allowed === nodeCheck + ? conversions > byNode + : conversions !== byNode + ) { + found.push( + `converted ${String(conversions)}x, plain Node.js ${String(byNode)}x${allowed === nodeCheck ? " (at most)" : ""}`, + ); + } + } + } + } + } + } + } catch (error: unknown) { + found.push(`the fixture fails: ${describe(error)}`); + } finally { + found.push(...(await clean(env))); + const callback = + given !== null && Object.hasOwn(fixture.callbacks ?? {}, position); + for (const { how, error } of late.splice(0)) { + if (callback && given !== null && calledOut(error, given)) { + calledBack.add(`${entry.id} ${position}`); + } else { + found.push(`${how} ${describe(error)}`); + } + } + if (wrapped.misuses > 0) { + found.push( + `runs a method that a middleware has replaced on context.log ${String(wrapped.misuses)}x (binden logs through the logger that it wraps)`, + ); + wrapped.misuses = 0; + } + for (const message of privateFailures.splice(0)) { + found.push( + `binden has taken a value of the test for one of its objects (${message})`, + ); + } + const misuses = (given?.misused?.() ?? 0) - misusedByApplication; + if (misuses > 0) { + found.push( + `runs a method that the application has replaced on a logger of binden ${String(misuses)}x (binden logs through the logger that it wraps)`, + ); + } + } + return { + findings: found.map((what) => ({ entry: entry.id, probe: at, what })), + examined, + failure, + accepted, + }; +} + +/** The positions of a baseline that are probed: each argument, and `deep` */ +function positions( + fixture: IFixture, + baseline: readonly unknown[], +): (readonly (number | string)[])[] { + return [ + ...baseline.map((_, index) => [index]), + ...(fixture.deep ?? []).filter((path) => exists(baseline, path)), + ]; +} + +/** + * Runs the first valid call of an entry point (on its default receiver): + * its receiver, its arguments and what it returns + */ +async function first( + entry: IEntry, + fixture: IFixture, + env: IEnv, +): Promise<{ + readonly receiver: unknown; + readonly values: readonly unknown[]; + readonly result: unknown; +}> { + const [made] = fixture.calls; + const receiving = entry.receivers?.[0] ?? null; + const receiver = + receiving === null + ? null + : await bounded(receiving.make(env), makeMs, "Its receiver"); + const values = + entry.kind === "get" || typeof made === "undefined" + ? [] + : await bounded(made(env, receiver), makeMs, "Its arguments"); + const result = called(entry, receiver, values); + if (result instanceof NetServer) { + env.cleanups.push(() => result.close()); + } + return { receiver, values, result }; +} + +/** + * Checks that each option, init and argument of the nested positions of a + * valid call is read at most once (see `counted()`), by the call and the + * readers of what it stores + */ +async function readOnce( + entry: IEntry, + fixture: IFixture, + receiving: IReceiver | null, + index: number, +): Promise { + const env = environment(); + const found: string[] = []; + const made = fixture.calls[index]; + const ms = fixture.io === true ? ioMs : pendingMs; + try { + const receiver = + receiving === null + ? null + : await bounded(receiving.make(env), makeMs, "Its receiver"); + const reads = new Map(); + const values = counted( + await bounded(made(env, receiver), makeMs, "Its arguments"), + fixture.deep ?? [], + reads, + ); + const outcome = await outcomeOf(() => called(entry, receiver, values), ms); + if (outcome.kind === "returned" || outcome.kind === "resolved") { + const result = outcome.kind === "returned" ? outcome.value : null; + for (const read of Object.values(entry.readers)) { + await outcomeOf(() => read(receiver, result, values), ms); + } + for (const read of Object.values(fixture.readers ?? {})) { + await outcomeOf(() => read(receiver, result, values), ms); + } + } + for (const [key, count] of reads) { + if (count > 1) { + found.push(`reads ${key} ${String(count)}x`); + } + } + } catch (error: unknown) { + found.push(`the fixture fails: ${describe(error)}`); + } finally { + found.push(...(await clean(env))); + for (const { how, error } of late.splice(0)) { + found.push(`${how} ${describe(error)}`); + } + } + return found.map((what) => ({ + entry: entry.id, + probe: `reads (call ${String(index)})`, + what, + })); +} + +// A relative path (of `sendFile()`) is of this directory, also without the +// `root` of its options (e.g. once a probe has replaced them) +const cwd = process.cwd(); +process.chdir(import.meta.dirname); +const [all, classes] = surface(); +const findings: IFinding[] = []; +let probes = 0; +/** + * The ids of the entry points that the surface has (also those that the + * entry points return or install, as they are found) + */ +const ids = new Set(all.map(({ id }) => id)); +/** + * The ids of the entry points that this shard has probed (or found without a + * baseline), in their order: each one of `ids` by one shard + */ +const visited: string[] = []; +/** The checks of the fixtures themselves, which the first shard makes */ +const checked: IFinding[] = []; +for (const id of Object.keys(fixtures)) { + if (!ids.has(id)) { + checked.push({ entry: id, probe: "-", what: "names nothing" }); + } +} +for (const id of Object.keys(receivers)) { + if (!classes.has(id)) { + checked.push({ entry: id, probe: "-", what: "names no class" }); + } +} +for (const id of exportedInstances) { + if (!Object.hasOwn(instances, id)) { + checked.push({ + entry: id, + probe: "-", + what: "an instance of an exported class that is not probed, without a reason (`instances`)", + }); + } +} +for (const id of Object.keys(instances)) { + if (!exportedInstances.has(id)) { + checked.push({ entry: id, probe: "-", what: "names no instance" }); + } +} +if (shard === 0) { + findings.push(...checked); +} + +/** + * The function that the first valid call of an entry point returns, as an + * entry point of its own (see `returned`): called by a function of the test + * on the function that a fresh call has returned (whose origin is the + * receiver of that call, see `origins`) + */ +function returnedBy(entry: IEntry, fixture: IFixture): IEntry { + return { + id: `${entry.id}=>()`, + kind: "method", + fn(this: unknown, ...values: unknown[]): unknown { + return Reflect.apply(this as IFunction, null, values); + }, + receivers: [ + fresh(async (env) => { + const { receiver, result } = await first(entry, fixture, env); + if (isObject(result)) { + origins.set(result, receiver); + } + return result; + }), + ], + readers: {}, + }; +} + +/** + * A function or an accessor that the first valid call of an entry point + * installs (see `installed`, e.g. `0.response.write`), as an entry point of + * its own: called (or read) by a function of the test on the object that a + * fresh call has installed it on, once that call has settled (also once the + * response that it is installed on is in a state, see `states`) + */ +async function installedBy( + entry: IEntry, + fixture: IFixture, + name: string, + own: IFixture, +): Promise { + const parts = name.split("."); + const key = parts.at(-1) ?? ""; + const holder = async (env: IEnv): Promise => { + const { receiver, values, result } = await first(entry, fixture, env); + if (isPromise(result)) { + await bounded(result, ioMs, "Its first valid call"); + } + const [head = "", ...rest] = parts.slice(0, -1); + let object: unknown = head === "this" ? receiver : values[Number(head)]; + for (const part of rest) { + object = Reflect.get(object as object, part); + } + return object as object; + }; + const env = environment(); + const method = await holder(env) + .then((object) => { + const descriptor = Reflect.getOwnPropertyDescriptor(object, key); + if (typeof descriptor === "undefined") { + // A fixture that names nothing + throw new TypeError(`${name} is not installed`); + } + return typeof descriptor.value === "function"; + }) + .finally(async () => { + findings.push( + ...(await clean(env)).map((what) => ({ + entry: entry.id, + probe: name, + what, + })), + ); + }); + return { + id: `${entry.id}=>${name}${method ? "()" : ""}`, + kind: method ? "method" : "get", + fn(this: object, ...values: unknown[]): unknown { + return method + ? Reflect.apply(Reflect.get(this, key) as IFunction, this, values) + : Reflect.get(this, key); + }, + receivers: + own.states === true + ? stated(holder, (made) => made, false) + : [fresh(holder)], + readers: {}, + }; +} + +/** + * A member of the object that the first valid call of an entry point returns + * (see `members`, e.g. `add()` of `response.cookies`), as an entry point of + * its own: called (constructed, read or set) by a function of the test on + * the object that a fresh call has returned + */ +function memberBy( + entry: IEntry, + fixture: IFixture, + name: string, + [kind, key, constructs]: IMember, +): IEntry { + return { + id: `${entry.id}=>.${name}`, + kind, + fn(this: object, ...values: unknown[]): unknown { + if (kind === "get") { + return Reflect.get(this, key); + } + if (kind === "set") { + return Reflect.set(this, key, values[0]); + } + const member = Reflect.get(this, key) as IFunction; + return constructs + ? Reflect.construct(member, values) + : Reflect.apply(member, this, values); + }, + receivers: [ + fresh(async (env) => { + const { receiver, result } = await first(entry, fixture, env); + if (isObject(result)) { + origins.set(result, receiver); + } + return result; + }), + ], + readers: {}, + }; +} + +/** + * What a position that the call examines no hostile value at lacks (see + * `IUntouched`): a position whose valid value is a function or an object + * (`kept`) names the reader that uses it later, which must exist, have run + * with a hostile value there and used it, or an alternative check, which + * must exist (see `alternativesOf()`) + */ +function usedBy( + entry: IEntry, + fixture: IFixture, + position: string, + untouched: IUntouched | string, + kept: boolean, + as = "untouched", +): string[] { + if (typeof untouched === "string") { + return kept + ? [ + `${as}, and its valid value is a function or an object: it names neither the reader that uses it later (\`later\`) nor an alternative check (\`instead\`)`, + ] + : []; + } else if ("instead" in untouched) { + if (untouched.instead === "unread") { + // Kept (e.g. by a closure), never read once the call has returned + const key = `${entry.id} ${position}`; + if (!unreadRun.has(key)) { + return [ + `${as}, and never read later (\`unread\`), but no reader has run with a value there that tells a later read (late-use)`, + ]; + } + return readLate.has(key) + ? [ + `${as}, and never read later (\`unread\`), but read once the call has returned (a trap of late-use has run)`, + ] + : []; + } else if (untouched.instead === "settled") { + // Read until the call has settled, never once it has + const key = `${entry.id} ${position}`; + if (!settledRun.has(key)) { + return [ + `${as}, and never read once the call has settled (\`settled\`), but no reader has run with a value there that tells a later read (late-use)`, + ]; + } + return readSettled.has(key) + ? [ + `${as}, and never read once the call has settled (\`settled\`), but read as its readers run (a trap of late-use has run)`, + ] + : []; + } else if (untouched.instead === "callbacks") { + // A callback that Node.js calls later, which `callbacks` checks + return Object.hasOwn(fixture.callbacks ?? {}, position) + ? [] + : [ + `${as}, and checked as a callback instead, which it does not list (\`callbacks\`)`, + ]; + } + if (!Object.hasOwn(fixture.alternatives ?? {}, untouched.instead)) { + return [ + `${as}, and checked by ${untouched.instead} instead, which is not one of its alternative checks (\`alternatives\`)`, + ]; + } + return checkedInstead.has(`${entry.id} ${untouched.instead}`) + ? [] + : [ + `${as}, and checked by its alternative check ${untouched.instead} instead, which has not run`, + ]; + } + const { later } = untouched; + const key = `${entry.id} ${position} ${later}`; + if (!Object.hasOwn({ ...entry.readers, ...fixture.readers }, later)) { + return [ + `${as}, and used later by ${later}, which is not one of its readers (\`readers\`)`, + ]; + } else if (!readLater.has(key)) { + return [ + `${as}, and used later by its reader ${later}, which has not run with a hostile value there`, + ]; + } else if (!usedLater.has(key)) { + return [ + `${as}, and used later by its reader ${later}, which has used no hostile value given there`, + ]; + } + return []; +} + +/** + * Runs the alternative checks of a fixture (see `IUntouched.instead`), each + * once, in the shard that probes its entry point (recorded in + * `checkedInstead` once it has run): what each finds, and each that no + * position names + */ +async function alternativesOf( + entry: IEntry, + fixture: IFixture, +): Promise { + const named = new Set(); + for (const untouched of [ + ...Object.values(fixture.untouched ?? {}), + ...Object.values(fixture.kept ?? {}), + ...Object.values(fixture.held ?? {}), + ]) { + if (typeof untouched !== "string" && "instead" in untouched) { + named.add(untouched.instead); + } + } + const found: IFinding[] = []; + for (const [name, check] of Object.entries(fixture.alternatives ?? {})) { + const at = `alternative ${name}`; + if (!named.has(name)) { + found.push({ entry: entry.id, probe: at, what: "no position names it" }); + } + try { + const failures = await bounded(check(), ioMs, "An alternative check"); + checkedInstead.add(`${entry.id} ${name}`); + found.push( + ...failures.map((what) => ({ entry: entry.id, probe: at, what })), + ); + } catch (error: unknown) { + found.push({ + entry: entry.id, + probe: at, + what: `fails: ${describe(error)}`, + }); + } + } + return found; +} + +/** + * A fresh value that stands for a function, a plain object or an array that + * every valid call shares (e.g. `noop`, or `silent`), so that its retention + * tells what the call keeps (see `retainedBy()`): a function that calls it, + * or a shallow copy. `null` for anything else (e.g. a class, a proxy, or an + * instance that every call shares), whose retention is not told: a position + * of one where the call accepts a hostile value is named in `held`. + */ +function standIn(value: object): object | null { + if (isProxy(value)) { + return null; + } else if (typeof value === "function") { + if (isClass(value)) { + return null; + } + const fn = value as IFunction; + return function standing(this: unknown, ...values: unknown[]): unknown { + return Reflect.apply(fn, this, values); + }; + } else if (Array.isArray(value)) { + return [...(value as unknown[])]; + } + return isPlain(value) ? { ...value } : null; +} + +/** + * What a call of an entry point keeps of the values of its positions, and + * whose retention is not told (see `keptCall()`), by their positions + */ +interface IKeptCall { + /** + * Weak references to the values given to the call whose retention it + * tells (none once the call has failed) + */ + readonly told: readonly (readonly [string, WeakRef])[]; + /** + * Weak references to the same values of a valid call that is never made, + * fresh ones (see `retainedBy()`): one that survives is held by the + * environment of the call, not given by the application + */ + readonly owners: readonly (readonly [string, WeakRef])[]; + /** The positions whose shared value has no stand-in (see `standIn()`) */ + readonly shared: readonly string[]; + /** What the call has returned (which lives on as the receiver does) */ + readonly result: unknown; +} + +/** + * Makes the arguments of a valid call of an entry point, each value of a + * position (a function or an object, of any class) fresh: a value that the + * fixture makes for each call is fresh as it is, and one that every call + * shares is replaced by a stand-in (see `standIn()`), the nested positions + * first, whose holders (plain objects and arrays) are copied, then a holder + * from the copy that has their stand-ins (see `replaced()`). Runs the call: + * what it keeps (see `IKeptCall`), and the values of a valid call that is + * never made, whose retention tells their owner: a value that the + * environment of the call holds (e.g. a context of an exchange, which its + * server keeps) survives without the call, a fresh value of the application + * (e.g. a `Cookie`, a `Headers` or a view) does not. + */ +async function keptCall( + entry: IEntry, + fixture: IFixture, + env: IEnv, + receiver: unknown, + index: number, +): Promise { + const made = fixture.calls[index]; + const original = await bounded(made(env, receiver), makeMs, "Its arguments"); + // A value given by both is shared (e.g. one of the module) + const again = await bounded(made(env, receiver), makeMs, "Its arguments"); + // Never given to the call: what the environment holds of it survives + const control = await bounded(made(env, receiver), makeMs, "Its arguments"); + let values: unknown[] = [...original]; + const told: (readonly (number | string)[])[] = []; + /** The paths of the values that the fixture makes for each call */ + const each: (readonly (number | string)[])[] = []; + const shared: string[] = []; + const paths = positions(fixture, original) + .filter((path) => isObject(valueAt(original, path))) + .sort((a, b) => b.length - a.length); + for (const path of paths) { + if (valueAt(original, path) === valueAt(again, path)) { + // What holds it is copied as it is (never an instance, whose copy + // would be an object derived from it), and so is a holder whose + // nested positions have stand-ins: from that copy + const current = values; + const holders = path + .slice(1) + .map((_, length) => valueAt(current, path.slice(0, length + 1))); + const standing = holders.every( + (holder) => Array.isArray(holder) || isPlain(holder), + ) + ? standIn(valueAt(values, path) as object) + : null; + const copy = + standing === null ? null : replaced(values, path, { value: standing }); + if (copy === null) { + shared.push(path.join(".")); + } else { + values = copy; + told.push(path); + } + } else { + told.push(path); + each.push(path); + } + } + const owners = each + .filter((path) => isObject(valueAt(control, path))) + .map( + (path) => + [ + path.join("."), + new WeakRef(valueAt(control, path) as object), + ] as const, + ); + const ms = fixture.io === true ? ioMs : pendingMs; + const outcome = await outcomeOf(() => called(entry, receiver, values), ms); + if (outcome.kind !== "returned" && outcome.kind !== "resolved") { + return { told: [], owners: [], shared: [], result: null }; + } + const result = outcome.kind === "returned" ? outcome.value : null; + return { + // What the call returns itself (e.g. the error of `formatStack()`) is + // the caller's, as its receiver is: not kept by the call + told: told + .filter((path) => valueAt(values, path) !== result) + .map( + (path) => + [ + path.join("."), + new WeakRef(valueAt(values, path) as object), + ] as const, + ), + owners, + shared, + result, + }; +} + +/** + * What the valid calls of an entry point keep (see `retainedBy()`), by their + * positions (as ` `) + */ +interface IRetained { + /** The positions whose valid value the call keeps */ + readonly kept: ReadonlySet; + /** + * The positions whose retention is not told: their valid value is held by + * the environment of the call (it survives without the call), or shared + * by every valid call without a stand-in (see `standIn()`) + */ + readonly untold: ReadonlySet; +} + +/** + * What the valid calls of an entry point keep, on its default receiver, of + * the valid values of their positions (functions and objects): a weak + * reference to a value given survives every full garbage collection of a + * while once the call has settled (see `keptRounds`), while its receiver and + * what it has returned live on, before the environments of the calls are + * cleaned up (e.g. before a response closes). A position whose value is + * owned by the environment (see `keptCall()`), or shared without a stand-in, + * is untold, whatever the class of its value. + */ +async function retainedBy( + entry: IEntry, + fixture: IFixture, + indexes: ReadonlySet, +): Promise { + const receiving = entry.receivers?.[0] ?? null; + const envs: IEnv[] = []; + const kept: unknown[] = []; + const untold = new Set(); + const found: string[] = []; + /** A weak reference of a call, by ` ` */ + type IRef = readonly [string, WeakRef]; + const at = + (index: number): ((ref: IRef) => IRef) => + ([position, ref]) => [`${String(index)} ${position}`, ref]; + const refs: IRef[] = []; + const owners: IRef[] = []; + try { + for (const index of indexes) { + const env = environment(); + envs.push(env); + try { + const receiver = + receiving === null + ? null + : await bounded(receiving.make(env), makeMs, "Its receiver"); + const keeps = await keptCall(entry, fixture, env, receiver, index); + kept.push(receiver, keeps.result); + refs.push(...keeps.told.map(at(index))); + owners.push(...keeps.owners.map(at(index))); + for (const position of keeps.shared) { + untold.add(`${String(index)} ${position}`); + } + } catch (error: unknown) { + found.push(`the fixture fails: ${describe(error)}`); + } + } + // Kept is what stays reachable once what the call has started has + // settled (e.g. the flush of a send, also on a loaded machine): what is + // reachable at every collection of a while + let alive = refs; + let held = owners; + for ( + let round = 0; + round < keptRounds && alive.length + held.length > 0; + round += 1 + ) { + await delay(round === 0 ? 0 : keptMs); + collect(); + alive = alive.filter(([, ref]) => typeof ref.deref() !== "undefined"); + held = held.filter(([, ref]) => typeof ref.deref() !== "undefined"); + } + for (const [key] of held) { + untold.add(key); + } + return { + kept: new Set( + alive.map(([key]) => key).filter((key) => !untold.has(key)), + ), + untold, + }; + } finally { + kept.length = 0; + for (const env of envs) { + found.push(...(await clean(env))); + } + for (const { how, error } of late.splice(0)) { + found.push(`${how} ${describe(error)}`); + } + findings.push( + ...found.map((what) => ({ entry: entry.id, probe: "kept", what })), + ); + } +} + +/** + * What a position that a call examines, but whose valid value it keeps, + * lacks (see `kept`): a reader (or an alternative check) that uses it, and + * at a position of a function used `later`, the use of each hostile function + * whose result is hostile that the call has accepted there (see `IKept`) + */ +function keptBy( + entry: IEntry, + fixture: IFixture, + position: string, + binding: IKept, + acceptedThere: ReadonlySet, + isFunction: boolean, +): string[] { + const found = usedBy(entry, fixture, position, binding, true, "kept"); + const { ignores = {} } = binding; + const later = "later" in binding ? binding.later : null; + const results = + isFunction && later !== null + ? [...acceptedThere].filter((name) => resultHostiles.has(name)) + : []; + for (const name of results) { + const used = usedLaterBy.has( + `${entry.id} ${position} ${String(later)} ${name}`, + ); + if (!used && !Object.hasOwn(ignores, name)) { + found.push( + `kept, and used later by its reader ${String(later)}, which has not told the hostile ${name} given there from the valid value (a hostile result is checked; \`ignores\` says why it is not)`, + ); + } else if (used && Object.hasOwn(ignores, name)) { + found.push( + `kept, and ignores the hostile ${name}, which its reader ${String(later)} uses`, + ); + } + } + for (const name of Object.keys(ignores)) { + if (!results.includes(name as IHostileName)) { + found.push( + `kept, and ignores the hostile ${name}, which the call does not accept there as a function used later`, + ); + } + } + return found; +} + +/** + * The fixtures of members that are being probed (see `members`): a fixture + * whose members are its own (e.g. of `child()` of a logger) is probed once + */ +const probing = new Set(); + +/** + * Probes an entry point with its fixture (on each of its receivers), and the + * functions that it returns or installs, and the members of what it returns + * (see `returned`, `installed` and `members`) + */ +async function run(entry: IEntry, fixture: IFixture): Promise { + visited.push(entry.id); + /** The positions probed, as the keys of `converts` name them */ + const covered = new Set(); + /** Whether a position (` `) is examined by a hostile value */ + const examined = new Map(); + /** + * The positions (` `) whose valid value is a function or + * an object, which the call keeps where it examines no hostile value (see + * `IUntouched`) + */ + const kept = new Set(); + /** + * The hostile values that the call has accepted, on the default receiver, + * at a position whose valid value is a function or an object that it + * examines (not `untouched`), by the position, whether that valid value is + * a function, and the calls that have them (whose retention is told, see + * `retainedBy()`): never `late`, which gives the call its valid value (once, + * from its holder), so that what it keeps there is that value, not a + * hostile one + */ + const acceptedAt = new Map>(); + const functions = new Set(); + const keeping = new Set(); + const { untouched = {} } = fixture; + for (const receiving of entry.receivers ?? [null]) { + const isDefault = receiving === null || receiving.name === ""; + for (const [index, made] of fixture.calls.entries()) { + probes += 1; + const { findings: failures, failure } = await probe( + entry, + fixture, + receiving, + index, + null, + null, + ); + findings.push(...failures); + if ( + (failures.length === 0 || !isDefault) && + entry.kind !== "get" && + !baselineStates.has(receiving?.state ?? "fresh") + ) { + const env = environment(); + let baseline: readonly unknown[] | null = null; + try { + const receiver = + receiving === null + ? null + : await bounded(receiving.make(env), makeMs, "Its receiver"); + baseline = await bounded( + made(env, receiver), + makeMs, + "Its arguments", + ); + } catch { + // The baseline has reported it + } + findings.push( + ...(await clean(env)).map((what) => ({ + entry: entry.id, + probe: "positions", + what, + })), + ); + const valid = baseline ?? []; + for (const path of positions(fixture, valid)) { + const position = path.join("."); + covered.add(position); + // A holder that is read late is of a nested position, and a use + // after the call of an object (or a function) + const value = valueAt(valid, path); + if (isDefault && isObject(value)) { + kept.add(`${String(index)} ${position}`); + } + for (const name of hostileNames.filter( + (hostileName) => + (hostileName !== "late" || path.length > 1) && + (!objectHostiles.has(hostileName) || isObject(value)) && + (!loggerHostiles.has(hostileName) || isLoggerValue(value)) && + (!resultHostiles.has(hostileName) || + (typeof value === "function" && !isClass(value))) && + (!contextHostiles.has(hostileName) || isContextValue(value)) && + (isDefault || + stateHostiles.has(hostileName) || + (typeof value === "function" && + functionHostiles.has(hostileName))), + )) { + probes += 1; + const outcomeOfHostile = await probe( + entry, + fixture, + receiving, + index, + path, + name, + failure, + ); + findings.push(...outcomeOfHostile.findings); + if ( + isDefault && + outcomeOfHostile.accepted && + name !== "late" && + isObject(value) && + !Object.hasOwn(untouched, position) + ) { + const names = acceptedAt.get(position) ?? new Set(); + acceptedAt.set(position, names.add(name)); + if (typeof value === "function" && !isClass(value)) { + functions.add(position); + } + keeping.add(index); + } + if ( + isDefault && + name !== "late" && + name !== "late-use" && + name !== "late-write" + ) { + const key = `${String(index)} ${position}`; + examined.set( + key, + (examined.get(key) ?? false) || outcomeOfHostile.examined, + ); + } + } + } + if ( + isDefault && + (fixture.deep ?? []).some((path) => exists(valid, path)) + ) { + probes += 1; + findings.push(...(await readOnce(entry, fixture, receiving, index))); + } + } + } + } + const needed = new Set(); + /** The untouched positions whose valid value is kept (see `kept`) */ + const keeps = new Set(); + for (const [key, was] of examined) { + const [index = "", position = ""] = key.split(" "); + if (!was && kept.has(key)) { + keeps.add(position); + } + if (!was && Object.hasOwn(untouched, position)) { + needed.add(position); + } else if (!was) { + findings.push({ + entry: entry.id, + probe: position, + what: `no hostile value is examined there (call ${index})`, + }); + } + } + findings.push(...(await alternativesOf(entry, fixture))); + for (const [position, why] of Object.entries(untouched)) { + if (!needed.has(position)) { + findings.push({ + entry: entry.id, + probe: position, + what: "listed as untouched, but a hostile value is examined there in every call", + }); + } + findings.push( + ...usedBy(entry, fixture, position, why, keeps.has(position)).map( + (what) => ({ entry: entry.id, probe: position, what }), + ), + ); + } + // What the call keeps of the positions where it accepts a hostile value + const retained = + keeping.size > 0 + ? await retainedBy(entry, fixture, keeping) + : { kept: new Set(), untold: new Set() }; + const accepted = (keys: ReadonlySet): Set => + new Set( + [...keys] + .map((key) => key.slice(key.indexOf(" ") + 1)) + .filter((position) => acceptedAt.has(position)), + ); + const keptAt = accepted(retained.kept); + // Where no call tells it (see `held`) + const untoldAt = new Set( + [...accepted(retained.untold)].filter((position) => !keptAt.has(position)), + ); + for (const position of untoldAt) { + if (!Object.hasOwn(fixture.held ?? {}, position)) { + findings.push({ + entry: entry.id, + probe: position, + what: `accepts hostile values there (${[...(acceptedAt.get(position) ?? [])].join(", ")}), but its valid value is held by the environment of the call, or shared by its valid calls without a stand-in, so that what the call keeps of it is not told: it names neither the reader that uses it later (\`held\`, \`later\`) nor an alternative check (\`instead\`)`, + }); + } + } + for (const [position, binding] of Object.entries(fixture.held ?? {})) { + findings.push( + ...(untoldAt.has(position) + ? usedBy(entry, fixture, position, binding, true, "held") + : [ + "listed as held, but what the call keeps of it is told (or it accepts no hostile value there)", + ] + ).map((what) => ({ entry: entry.id, probe: position, what })), + ); + } + for (const position of keptAt) { + if (!Object.hasOwn(fixture.kept ?? {}, position)) { + findings.push({ + entry: entry.id, + probe: position, + what: `keeps its valid value, a function or an object, and accepts hostile values there (${[...(acceptedAt.get(position) ?? [])].join(", ")}): it names neither the reader that uses it later (\`kept\`, \`later\`) nor an alternative check (\`instead\`)`, + }); + } + } + for (const [position, binding] of Object.entries(fixture.kept ?? {})) { + findings.push( + ...(keptAt.has(position) + ? keptBy( + entry, + fixture, + position, + binding, + acceptedAt.get(position) ?? new Set(), + functions.has(position), + ) + : [ + "listed as kept, but no valid call keeps its value while it accepts a hostile value there", + ] + ).map((what) => ({ entry: entry.id, probe: position, what })), + ); + } + if (fixture.dropsLog === true && !droppedLog.has(entry.id)) { + findings.push({ + entry: entry.id, + probe: "dropsLog", + what: "drops what the `log` getter of a context throws (`dropsLog`), but no getter of the test has failed in its calls", + }); + } + for (const reader of fixture.logs ?? []) { + if (!loggedBy.has(`${entry.id} ${reader}`)) { + findings.push({ + entry: entry.id, + probe: reader, + what: "listed as a reader that logs as the application does (`logs`), but it has run no method that the application has replaced on a logger of binden", + }); + } + } + for (const position of Object.keys(fixture.callbacks ?? {})) { + if (!calledBack.has(`${entry.id} ${position}`)) { + findings.push({ + entry: entry.id, + probe: position, + what: "listed as a callback, but no function given there is called later", + }); + } + } + for (const position of Object.keys(fixture.converts ?? {})) { + if (!covered.has(position)) { + findings.push({ + entry: entry.id, + probe: position, + what: "a documented conversion of a position not probed", + }); + } else if (!converting.has(`${entry.id} ${position}`)) { + findings.push({ + entry: entry.id, + probe: position, + what: "a documented conversion that no probe runs", + }); + } + } + if (typeof fixture.returned !== "undefined") { + const returned = returnedBy(entry, fixture); + ids.add(returned.id); + await run(returned, fixture.returned); + } + for (const [name, own] of Object.entries(fixture.installed ?? {})) { + let installed: IEntry | null = null; + try { + installed = await installedBy(entry, fixture, name, own); + } catch (error: unknown) { + findings.push({ + entry: entry.id, + probe: name, + what: `the fixture fails: ${describe(error)}`, + }); + } + if (installed !== null) { + ids.add(installed.id); + await run(installed, own); + } + } + const { members: memberFixtures } = fixture; + if (typeof memberFixtures !== "undefined" && !probing.has(memberFixtures)) { + probing.add(memberFixtures); + const env = environment(); + let result: unknown = null; + try { + ({ result } = await first(entry, fixture, env)); + } catch (error: unknown) { + findings.push({ + entry: entry.id, + probe: "members", + what: `the fixture fails: ${describe(error)}`, + }); + } finally { + findings.push( + ...(await clean(env)).map((what) => ({ + entry: entry.id, + probe: "members", + what, + })), + ); + } + const found = membersOf(result); + for (const [name, own] of Object.entries(memberFixtures)) { + const member = found.get(name); + if (typeof member === "undefined") { + findings.push({ + entry: `${entry.id}=>.${name}`, + probe: "-", + what: "names nothing", + }); + } else { + const probedMember = memberBy(entry, fixture, name, member); + ids.add(probedMember.id); + await run(probedMember, own); + } + } + // An accessor without a fixture is read + for (const [name, member] of found) { + if (member[0] === "get" && !Object.hasOwn(memberFixtures, name)) { + const probedMember = memberBy(entry, fixture, name, member); + ids.add(probedMember.id); + await run( + probedMember, + call(() => []), + ); + } + } + probing.delete(memberFixtures); + } +} + +/** + * The measured costs of the heaviest entry points, in ms (a run of each, with + * what it returns, installs and has as members, on the reference machine, + * moderately loaded): the shards are balanced by them (see `shardsOf()`), + * and any other entry point costs `lightCost` + */ +const costs: Readonly> = { + "@binden/compression:Compression#run()": 580, + "binden:new Binden()": 340, + "binden:BindenResponse#[binden.ownership]()": 320, + "@binden/multipart:new Multipart()": 300, + "binden:Context#sendFile()": 300, + "binden:BindenResponse#sendFile()": 270, + "binden:BindenResponse#writeInformation()": 270, + "@binden/compression:new Compression()": 240, + "@binden/multipart:default()": 200, + "binden:Context#throw()": 170, + "binden:BindenResponse#send()": 170, + "@binden/body:new BodyParser()": 160, + "binden:BindenResponse#writeHead()": 160, + "binden:new Context()": 130, + "@binden/logger:createSerializers()": 130, + "binden:Context#json()": 120, + "binden:Context#redirect()": 120, + "binden:BindenResponse#addTrailers()": 110, + "binden:BindenResponse#clearCookie()": 110, + "binden:BindenResponse#writeHeader()": 110, + "binden:BindenResponse#end()": 110, + "binden:Context#clearCookie()": 110, + "binden:BindenResponse#write()": 100, + "binden:BindenResponse#json()": 100, + "binden:Context#log": 100, + "binden:BindenResponse#set()": 90, + "binden:BindenResponse#redirect()": 80, + "binden:BindenResponse#[binden.pipe_failure]()": 70, + "binden:BindenResponse#appendHeader()": 70, + "binden:BindenResponse#writeEarlyHints()": 70, + "binden:BindenResponse#[binden.negotiator]()": 60, + "binden:createSerializers()": 60, + "binden:BindenResponse#setHeaders()": 60, + "binden:Binden#createSecureServer()": 60, + "binden:BindenResponse#destroy()": 60, + "@binden/body:BodyParser#run()": 60, + "binden:BindenResponse#[binden.listen]()": 60, + "binden:Context#send()": 50, + "binden:BindenResponse#[binden.validate_head]()": 50, + "binden:Stack#use()": 40, + "binden:BindenResponse#setTimeout()": 40, + "binden:BindenResponse#once()": 40, + "@binden/multipart:Multipart#run()": 40, +}; +const lightCost = 10; +for (const id of Object.keys(costs)) { + if (shard === 0 && !ids.has(id)) { + findings.push({ + entry: id, + probe: "-", + what: "a cost of an entry point that names nothing (`costs`)", + }); + } +} + +/** + * The shard of each entry point (by its id): the costliest first (see + * `costs`), each to the shard of the least cost so far (the first of them), + * as every shard computes it + */ +function shardsOf(selected: readonly IEntry[]): ReadonlyMap { + const cost = ({ id }: IEntry): number => costs[id] ?? lightCost; + const loads = Array.from({ length: shards }, () => 0); + const found = new Map(); + for (const entry of [...selected].sort((a, b) => cost(b) - cost(a))) { + const least = loads.indexOf(Math.min(...loads)); + found.set(entry.id, least); + loads[least] = (loads[least] ?? 0) + cost(entry); + } + return found; +} + +const selected = all.filter(({ id }) => id.includes(only)); +const assigned = shardsOf(selected); +for (const entry of selected.filter(({ id }) => assigned.get(id) === shard)) { + if (Object.hasOwn(fixtures, entry.id)) { + await run(entry, fixtures[entry.id]); + } else if (entry.kind === "get") { + // A getter takes no argument: it is read on a fresh receiver + await run( + entry, + call(() => []), + ); + } else { + visited.push(entry.id); + findings.push({ entry: entry.id, probe: "-", what: "no baseline" }); + } +} + +process.chdir(cwd); +server.closeAllConnections(); +server.close(); +// Exits once the report is written (a pipe may take it asynchronously) +process.stdout.write( + args.includes("--lines") + ? `${[ + ...findings.map( + ({ entry, probe: at, what }) => `${entry} | ${at}: ${what}`, + ), + `${String(all.length)} entries, ${String(probes)} probes, ${String(findings.length)} findings`, + ].join("\n")}\n` + : `${JSON.stringify({ entries: [...ids], probed: visited, probes, findings })}\n`, + () => { + process.exit(0); + }, +); diff --git a/packages/binden/test/fixtures/queued-replies.ts b/packages/binden/test/fixtures/queued-replies.ts new file mode 100644 index 00000000..e1e16612 --- /dev/null +++ b/packages/binden/test/fixtures/queued-replies.ts @@ -0,0 +1,97 @@ +// Run by `retention.test.ts` with `--expose-gc`: 100 clients each pipeline a +// long-lived `/events` (without a deadline) and a `/small` that ends its +// reply at once, which waits for `/events` while its deadline passes (it is +// sealed), then go away (once every `/small` has been sealed, however loaded +// the machine is). Every `/small` must be finalized (its cleanup run) and +// collected, and a shutdown must resolve. Prints what is left. +import { type AddressInfo, connect } from "node:net"; +import { + Binden, + type BindenAbortError, + BindenResponse, + type Context, + shutdown, +} from "../../index.js"; +import { once } from "node:events"; +import { pollUntil } from "../helpers.js"; +import { queryObjects } from "node:v8"; + +const clients = 100; +let cleaned = 0; +// The `/small` requests whose deadline has passed while they wait +let sealed = 0; +const { promise: everySealed, resolve: allSealed } = + Promise.withResolvers(); + +setTimeout(() => { + process.stderr.write(`Timed out: ${sealed} of ${clients} sealed\n`); + process.exit(2); +}, 25_000).unref(); + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const app = new Binden({ timeout: 30 }).use(async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/events") { + context.setTimeout(0); + response.setHeader("Content-Type", "text/event-stream"); + response.write("data: hi\n\n"); + await once(response, "close"); + return; + } + context.signal.addEventListener("abort", () => { + if ((context.signal.reason as BindenAbortError).reason === "deadline") { + sealed += 1; + if (sealed === clients) { + allSealed(null); + } + } + }); + context.onCleanup(() => { + cleaned += 1; + }); + response.setHeader("Content-Type", "text/plain"); + response.end("x".repeat(64 * 1024)); +}); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +await Promise.all( + Array.from({ length: clients }, async () => { + const socket = connect(port, "127.0.0.1"); + socket.on("error", () => null); + await once(socket, "connect"); + socket.write( + "GET /events HTTP/1.1\r\nHost: a\r\n\r\nGET /small HTTP/1.1\r\nHost: a\r\n\r\n", + ); + // Not after a fixed time: a `/small` that has not come yet would never be + // sealed (nor be counted) + await everySealed; + socket.destroy(); + }), +); +// Every cleanup runs once its client has gone away (within 5 s, however +// loaded the machine is: a missing one is printed) +await pollUntil(() => cleaned === clients, 5000).catch(() => null); +await sleep(100); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + gc(); + await sleep(20); +} +const left = queryObjects(BindenResponse, { format: "count" }); +const closed = await Promise.race([ + shutdown(server, { timeout: 100, cleanup_timeout: 200 }).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ), + sleep(3000).then(() => "pending"), +]); +process.stdout.write( + `cleanups=${cleaned} ${left < 10 ? "collected" : `kept ${left}`} shutdown ${closed}\n`, +); diff --git a/packages/binden/test/fixtures/refused-head.ts b/packages/binden/test/fixtures/refused-head.ts new file mode 100644 index 00000000..db0845ec --- /dev/null +++ b/packages/binden/test/fixtures/refused-head.ts @@ -0,0 +1,79 @@ +// Run by `process.test.ts`: a reason phrase or a header that Node.js refuses +// when it sends the headers (given to `writeHead()`, assigned, or a `Trailer` +// of a body that is not chunked) must neither crash the process nor keep the +// request without a reply: the error handler replies `500` +import { Binden, type Context } from "../../index.js"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, () => { + process.stderr.write(`${event}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const invalid = "bad\n"; +const routes: Partial unknown>> = { + "/reason": ({ response }) => response.writeHead(200, invalid), + "/header": ({ response }) => response.writeHead(200, { "X-A": invalid }), + "/pairs": ({ response }) => response.writeHead(200, ["X-A"]), + "/assigned": async ({ response }) => { + response.statusMessage = invalid; + await response.send("body"); + }, + "/assigned-empty": async ({ response }) => { + response.statusMessage = invalid; + await response.send(); + }, + "/stream": async ({ response }) => { + response.statusMessage = invalid; + await response.send(Readable.from(["body"])); + }, + "/empty-stream": async ({ response }) => { + response.statusMessage = invalid; + await response.send(Readable.from([])); + }, + "/file": async ({ response }) => { + response.statusMessage = invalid; + await response.sendFile(import.meta.filename); + }, + "/flush": async ({ response }) => { + response.statusMessage = invalid; + await response.flush(); + }, + "/trailer": async ({ response }) => { + response.setHeader("Trailer", "X-T"); + await response.send("body"); + }, +}; + +const app = new Binden().use(async (context: Context) => { + await routes[context.url.pathname]?.(context); +}); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const replies: string[] = []; +for (const path of Object.keys(routes)) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + replies.push(`${response.statusCode} ${response.statusMessage}`); +} + +server.close(); +process.stdout.write( + `${[...new Set(replies)].join(", ")} (${replies.length})\n`, +); diff --git a/packages/binden/test/fixtures/released-readables.ts b/packages/binden/test/fixtures/released-readables.ts new file mode 100644 index 00000000..c5e03dbd --- /dev/null +++ b/packages/binden/test/fixtures/released-readables.ts @@ -0,0 +1,111 @@ +// Run by `released-readables.test.ts` with `--expose-gc`: once destroyed, the +// readable of a stream-like source (see `readStreamLike()`) holds nothing of +// the response that sends it (its cancellation reason, its logs, and what +// tells that it is left untouched), so that what keeps the readable and its +// source (e.g. a pending report of a late error, see `reportOnce()`) keeps +// nothing of the response. Prints, by case, whether the stand-in of the +// response is collected while the readable and its source are kept. +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../../src/stream-like.js"; +import { EventEmitter } from "node:events"; +import type { Readable } from "node:stream"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +/** A stream-like source of its own */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(): this { + return this; + } +} + +/** Ignores an error (a function of the module, which holds nothing) */ +function ignore(): void { + // The readable fails without anybody to tell +} + +/** The stand-in of a response, whose instances are counted */ +class Response { + public response = true; +} + +/** A readable and its source, kept */ +interface ISent { + readonly readable: Readable; + readonly source: Source; +} + +/** Reads a source for a stand-in of a response, which only its hooks hold */ +function sent(): ISent { + const response = new Response(); + const source = new Source(); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error(`Cancelled ${String(response.response)}`), + // The report of a late error holds nothing of the response + ignore, + () => { + Reflect.get(response, "response"); + }, + { + released: (): void => { + Reflect.get(response, "response"); + }, + removed: (): void => { + Reflect.get(response, "response"); + }, + }, + ); + readable.on("error", ignore); + return { readable, source }; +} + +/** The ways a readable is destroyed */ +const cases: Record void> = { + cancelled: ({ readable }) => { + readable.destroy(); + }, + failed: ({ source }) => { + source.emit("error", new Error("Failed")); + }, + ended: ({ readable, source }) => { + readable.resume(); + source.emit("end"); + }, +}; + +const gc = Reflect.get(globalThis, "gc") as () => void; +const lines: string[] = []; +for (const [name, destroy] of Object.entries(cases)) { + const sending = sent(); + destroy(sending); + // Its release, then its listener of `error` removed on the next turn + for (let turn = 0; turn < 3; turn += 1) { + await new Promise(setImmediate); + } + gc(); + await new Promise(setImmediate); + gc(); + const { readable, source } = sending; + const left = queryObjects(Response, { format: "count" }); + lines.push( + `${name} ${String(readable.destroyed)} ${left === 0 ? "collected" : "kept"} ${String(source.listenerCount("error"))}`, + ); +} +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/retention.ts b/packages/binden/test/fixtures/retention.ts new file mode 100644 index 00000000..60f2bad2 --- /dev/null +++ b/packages/binden/test/fixtures/retention.ts @@ -0,0 +1,61 @@ +// Run by `retention.test.ts` with `--expose-gc`: what outlives a request in +// the async context of one of its works (`timers`: a long timer that it has +// created; `shared`: a listener of `end` that it has left on a long-lived +// source, in the window of its pipe) must not keep the request, once it has +// completed. Prints how many responses are left after a full collection. +import { Agent, get } from "node:http"; +import { Binden, BindenResponse, type Context } from "../../index.js"; +import type { AddressInfo } from "node:net"; +import { PassThrough } from "node:stream"; +import { once } from "node:events"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [mode = "timers", count = "4000"] = process.argv.slice(2); +const requests = Number(count); +const shared = new PassThrough(); +shared.setMaxListeners(0); + +const app = new Binden().use(async (context: Context) => { + if (mode === "timers") { + // E.g. the expiry of a cache entry + setTimeout(() => null, 600_000).unref(); + } else { + shared.pipe(context.response); + // In the window of the pipe: bound to its work + shared.once("end", () => null); + shared.unpipe(context.response); + } + await context.text("ok"); +}); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const agent = new Agent({ keepAlive: true, maxSockets: 8 }); +const request = (): Promise => + new Promise((resolve, reject) => { + get({ port, agent, host: "127.0.0.1" }, (response) => { + response.resume().once("end", resolve); + }).once("error", reject); + }); +for (let sent = 0; sent < requests; sent += 8) { + await Promise.all(Array.from({ length: 8 }, request)); +} +agent.destroy(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const left = queryObjects(BindenResponse, { format: "count" }); +process.stdout.write(`${left < 100 ? "collected" : `kept ${left}`}\n`); diff --git a/packages/binden/test/fixtures/revoked-sources.ts b/packages/binden/test/fixtures/revoked-sources.ts new file mode 100644 index 00000000..4ceaeae4 --- /dev/null +++ b/packages/binden/test/fixtures/revoked-sources.ts @@ -0,0 +1,72 @@ +// Run by `revoked-sources.test.ts`, without handlers of the process: the +// sources of a failed pipe of a work are unpiped once the work is revoked (see +// `[kRevoke]()`), on a tick of their own. A source whose cleanup throws (its +// `unpipe()`, or a trap of a proxy) must not crash the process: it is only +// logged, the other sources are unpiped all the same, and the revocation +// resolves. Nor does the revocation of a work that is `undefined`, without a +// failure. +import { kOwnership, kPipeFailure, kRevoke } from "../../src/seal.js"; +import { BindenResponse } from "../../index.js"; +import { EventEmitter } from "node:events"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const response = new BindenResponse(new IncomingMessage(new Socket())); +const logged: unknown[] = []; +response[kOwnership]({ + fail: () => false, + guards: () => false, + replier: () => null, + native: () => false, + debug: (message: string, error: unknown) => { + logged.push(`${message}: ${(error as Error).message}`); + }, + warn: () => null, + note: () => null, +}); + +/** A source piped into the response, whose `unpipe()` records its calls */ +class Recorded extends EventEmitter { + public calls = 0; + public constructor() { + super(); + // Its state, as Node.js keeps it, which pipes into the response + Object.assign(this, { _readableState: { pipes: [response] } }); + } + public unpipe(destination: unknown): this { + this.calls += destination === response ? 1 : 0; + return this; + } +} + +/** A source piped into the response, whose `unpipe()` throws */ +class Hostile extends Recorded { + // eslint-disable-next-line class-methods-use-this -- throws + public override unpipe(): this { + throw new Error("A hostile unpipe"); + } +} + +const owner = {}; +const recorded = new Recorded(); +for (const source of [ + new Hostile(), + new Proxy(new EventEmitter(), { + getPrototypeOf(): never { + throw new Error("A hostile trap"); + }, + }), + recorded, +]) { + response[kPipeFailure](owner, new Error("A failure"), source); +} +await response[kRevoke]([owner], null); +process.stdout.write(`${JSON.stringify([recorded.calls, logged])}\n`); +// eslint-disable-next-line no-undefined -- a work that is `undefined` +await response[kRevoke]([undefined as never], null); +process.stdout.write("an undefined work: revoked\n"); diff --git a/packages/binden/test/fixtures/send-file-release.ts b/packages/binden/test/fixtures/send-file-release.ts new file mode 100644 index 00000000..1482207c --- /dev/null +++ b/packages/binden/test/fixtures/send-file-release.ts @@ -0,0 +1,214 @@ +// Run by `process.test.ts` with `--expose-gc`: the file of a `sendFile()` +// whose send is cancelled, or would be ignored, while the file is opened or +// its metadata read (an error handler that fails or times out, a deadline +// once the headers are flushed) is closed by binden, never left to the +// collection of its handle (which crashes Node.js 26, and warns on Node.js +// 24), and no descriptor is left open. Prints what each request has got, and +// what is left. +import { Binden, type Context } from "../../index.js"; +import { existsSync, readdirSync } from "node:fs"; +import fsp, { type FileHandle, mkdtemp, rm, writeFile } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import { once } from "node:events"; +import { pollUntil } from "../helpers.js"; +import { request } from "node:http"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const warnings: string[] = []; +process.on("warning", ({ name, message }) => { + warnings.push(`${name}: ${message}`); +}); + +/** The open descriptors of the process (none where `/dev/fd` is missing, e.g. on Windows) */ +function descriptors(): number { + return existsSync("/dev/fd") ? readdirSync("/dev/fd").length : 0; +} + +const tmp = await mkdtemp(join(tmpdir(), "binden-release-")); +const file = join(tmp, "page.html"); +await writeFile(file, "

error

"); + +/** + * What the requests of a scenario have done: the handles that their + * `sendFile()` has opened (and the opens still pending), the streams that it + * has created of them, and how it has settled + */ +interface IScenario { + readonly slow: boolean; + readonly handles: FileHandle[]; + opening: number; + streams: number; + readonly outcomes: string[]; +} + +const scenario = (slow: boolean): IScenario => ({ + slow, + handles: [], + opening: 0, + streams: 0, + outcomes: [], +}); + +/** The scenario of the requests sent now (an open or a send keeps its own) */ +let current = scenario(false); +const { open } = fsp; +fsp.open = async (...args: Parameters): Promise => { + const of = current; + of.opening += 1; + try { + const handle = await open(...args); + of.handles.push(handle); + const stat = handle.stat.bind(handle); + handle.stat = (async () => { + if (of.slow) { + // As on a slow disk + await sleep(100); + } + return stat(); + }) as FileHandle["stat"]; + const create = handle.createReadStream.bind(handle); + handle.createReadStream = ( + ...options: Parameters + ): ReturnType => { + of.streams += 1; + return create(...options); + }; + return handle; + } finally { + of.opening -= 1; + } +}; +syncBuiltinESMExports(); + +/** Records how a `sendFile()` settles */ +const track = (sent: Promise): Promise => { + const { outcomes } = current; + sent.then( + () => { + outcomes.push("resolved"); + }, + (error: unknown) => { + const { code, message } = error as { code?: unknown; message?: unknown }; + outcomes.push(`rejected:${String(code ?? message)}`); + }, + ); + return sent; +}; + +const app = new Binden({ + timeout: 0, + error_handler_timeout: 30, + error_handler: async (context: Context): Promise => { + const { pathname } = context.url; + if (pathname === "/throws") { + // Started, then the handler fails before it awaits it + track(context.response.sendFile(file)).catch(() => null); + throw new Error("handler fails"); + } else if (pathname === "/all") { + // E.g. an audit that fails while the page is sent + await Promise.all([ + track(context.response.sendFile(file)), + Promise.reject(new Error("audit fails")), + ]); + } else { + // `/expired`: the metadata comes after `error_handler_timeout` + await track(context.response.sendFile(file)); + } + }, +}).use(async (context: Context) => { + if (context.url.pathname === "/flushed") { + // The deadline passes while the metadata is read: it cancels nothing + // once the headers are sent, and cuts the response + context.setTimeout(30); + context.response.flushHeaders(); + await track(context.response.sendFile(file)); + return; + } + throw new Error("The route fails"); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +/** The status of a response, `(cut)` when its connection closes before its end */ +const get = (path: string): Promise => + new Promise((resolve) => { + request({ agent: false, path, port, host: "127.0.0.1" }, (response) => { + response.resume(); + response.once("end", () => { + resolve(String(response.statusCode)); + }); + response.once("error", () => { + resolve(`${response.statusCode}(cut)`); + }); + }) + .once("error", () => { + resolve("cut"); + }) + .end(); + }); + +/** + * Whether every `sendFile()` of a scenario has settled, and every file that + * they have opened is closed, within 10 s (a guard against a hang) + */ +const settled = (of: IScenario, requests: number): Promise => + pollUntil( + () => + of.outcomes.length >= requests && + of.opening === 0 && + of.handles.every(({ fd }) => fd === -1), + ).then( + () => true, + () => false, + ); + +const before = descriptors(); +const lines: string[] = []; +for (const [path, slow] of [ + ["/throws", false], + ["/all", false], + ["/expired", true], + ["/flushed", true], +] as const) { + current = scenario(slow); + const statuses = new Set(); + // A file that one request leaves open would show up three times + const requests = 3; + for (let index = 0; index < requests; index += 1) { + statuses.add(await get(path)); + } + const closed = await settled(current, requests); + const { outcomes, streams, handles } = current; + const left = handles.filter(({ fd }) => fd !== -1).length; + lines.push( + `${path} ${[...statuses].join(",")} ${[...new Set(outcomes)].join(",")} streams=${streams} ${closed ? "closed" : `open=${left}`}`, + ); +} +server.close(); +await sleep(50); +const growth = descriptors() - before; + +// What a handle that is still open would do once it is collected +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + gc(); + await sleep(20); +} +await rm(tmp, { recursive: true, force: true }); +process.stdout.write( + `${lines.join("\n")}\nfd growth=${Math.max(growth, 0)} warnings=${JSON.stringify(warnings)}\n`, +); diff --git a/packages/binden/test/fixtures/sigterm.ts b/packages/binden/test/fixtures/sigterm.ts new file mode 100644 index 00000000..9fc99e83 --- /dev/null +++ b/packages/binden/test/fixtures/sigterm.ts @@ -0,0 +1,55 @@ +// 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. No step depends on +// timing: SIGTERM is sent once the request is in progress (the client never +// connects to a server that has stopped listening), and the request completes +// once the shutdown has begun. +import { Binden, shutdown } from "../../index.js"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const received = Promise.withResolvers(); +const terminated = Promise.withResolvers(); + +const app = new Binden().use(async (context) => { + received.resolve(null); + await terminated.promise; + await context.send("done"); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const closing = new Promise((resolve, reject) => { + process.once("SIGTERM", () => { + shutdown(server).then(resolve, reject); + terminated.resolve(null); + }); +}); + +const pending = new Promise((resolve, reject) => { + request({ agent: false, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); +}); +await received.promise; +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/fixtures/stale-stacks.ts b/packages/binden/test/fixtures/stale-stacks.ts new file mode 100644 index 00000000..bbde096d --- /dev/null +++ b/packages/binden/test/fixtures/stale-stacks.ts @@ -0,0 +1,231 @@ +// Run by `stale-stacks.test.ts`, without a handler of the process: a +// stream-like source whose value (an error that it emits, `emit`, or that its +// `pause()` throws once the response is full, `pause`, or its `resume()`, +// `resume`) reads safely when binden checks it, but whose `stack` code of the +// application redefines as a getter that throws before Node.js reads it again: +// a getter of its `name`, which V8 reads while it formats the stack (`name`), +// a `prepareStackTrace` of the application (`pst`), a getter of the state of +// a writable that Node.js reads on a `Readable` as it destroys it, before it +// reads the stack (`proto`), or the `destroy()` of the source, given that value +// (`redefine`). The send must reject with that value at once, destroy the +// source once, throw nothing into its events (`threw=`), and leave its guard, +// so that a later error of the source is logged. Prints, for the kind (the +// first argument) and each way (the other ones), the status, how the send has +// settled, what the source has been destroyed with (the value, `V`, its +// carrier, `carrier`, or nothing, `none`), what its events have thrown, and +// the late errors logged. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context, releasedFailure } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { capture, pollUntil } from "../helpers.js"; +import { Readable } from "node:stream"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +const [kind = "name", ...ways] = process.argv.slice(2); + +/** Redefines the `stack` of an error as a getter that throws */ +function unstack(error: Error): void { + Object.defineProperty(error, "stack", { + configurable: true, + get(): never { + throw new Error("Its stack has been redefined"); + }, + }); +} + +/** The errors whose stack a `prepareStackTrace` of the application redefines (`pst`) */ +const marked = new WeakSet(); +if (kind === "pst") { + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + if (marked.has(error)) { + unstack(error); + } + return [`${error.name}: ${error.message}`, ...frames.map(String)].join( + "\n at ", + ); + }, + ); +} + +/** The value of a source, by the kind */ +function valueOf(): Error { + const value = new Error("V"); + if (kind === "name") { + Object.defineProperty(value, "name", { + configurable: true, + get(): string { + unstack(value); + return "Error"; + }, + }); + } else if (kind === "pst") { + marked.add(value); + } + return value; +} + +/** + * Before the value is brought (`proto`): the next read of the state of a + * writable on a `Readable` (Node.js reads it as it destroys one, before it + * reads the stack) redefines its `stack`, once + */ +function arm(value: Error): void { + if (kind === "proto") { + Object.defineProperty(Readable.prototype, "_writableState", { + configurable: true, + get(): void { + Reflect.deleteProperty(Readable.prototype, "_writableState"); + unstack(value); + }, + // A duplex created meanwhile (none is) has its own + set(this: object, state: unknown): void { + Object.defineProperty(this, "_writableState", { + configurable: true, + enumerable: true, + value: state, + writable: true, + }); + }, + }); + } +} + +/** A stream-like source of its own that brings its value in its way */ +class Source extends EventEmitter { + readonly #way: string; + public readonly value = valueOf(); + /** What it has been destroyed with */ + public readonly destroys: string[] = []; + /** What its events have thrown into the code that emits them */ + public readonly threw: unknown[] = []; + #thrown = false; + + public constructor(way: string) { + super(); + this.#way = way; + } + + public pause(): this { + return this.#bring("pause"); + } + + public resume(): this { + return this.#bring("resume"); + } + + public destroy(error?: Error): this { + const { value } = this; + this.destroys.push( + error === value + ? "V" + : error?.cause === value + ? "carrier" + : (error?.message ?? "none"), + ); + if (kind === "redefine" && typeof error !== "undefined") { + unstack(error); + } + return this; + } + + /** Emits an event, recording what it throws */ + public fire(event: string, value: unknown): void { + if (value === this.value) { + arm(this.value); + } + try { + this.emit(event, value); + } catch (error: unknown) { + this.threw.push(error); + } + } + + /** Throws its value once, from the method of its way */ + #bring(method: string): this { + if (this.#way === method && !this.#thrown) { + this.#thrown = true; + arm(this.value); + throw this.value; + } + return this; + } +} + +const sources = new Map(); +const outcomes = new Map(); +const captured = capture(); +const app = new Binden({ + logger: captured.logger, + log_error_details: true, +}).use(async (context: Context) => { + const way = context.url.pathname.slice(1); + const source = new Source(way); + sources.set(way, source); + // Until the response is full, and the readable of the send + let chunks = 0; + const timer = setInterval(() => { + chunks += 1; + if (way === "emit") { + clearInterval(timer); + source.fire("error", source.value); + } else if (way !== "pause" || chunks > 2000) { + clearInterval(timer); + } else { + source.fire("data", Buffer.alloc(65_536, 0x61)); + } + }, 1); + const outcome = await context.send(source).then( + () => "resolved", + (error: unknown) => + `rejected:${error === source.value ? "V" : (error as Error).message}`, + ); + clearInterval(timer); + outcomes.set(way, outcome); + throw new Error(outcome); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const lines: string[] = []; +for (const way of ways) { + const socket = connect(port, "127.0.0.1"); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + socket.on("error", () => null); + const closed = once(socket, "close"); + socket.write(`GET /${way} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`); + if (way === "pause") { + // The client reads nothing until the send has settled: the response fills + socket.pause(); + } + await pollUntil(() => outcomes.has(way)); + socket.resume(); + await closed; + const source = sources.get(way) as Source; + const before = captured.records.length; + source.fire("error", new Error("Later")); + // Reported on a later turn + await new Promise(setImmediate); + const late = captured.records + .slice(before) + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); + const status = /^HTTP\/1\.1 (?\d{3})/u.exec(received)?.groups?.code; + const threw = source.threw.map((error) => (error as Error).message); + lines.push( + `${kind}:${way} ${status ?? "none"} ${outcomes.get(way)} destroys=${source.destroys.join(",")} threw=${threw.join(",")} late=${late.join(",")}`, + ); +} +server.close(); +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/stream-like-destroyed.ts b/packages/binden/test/fixtures/stream-like-destroyed.ts new file mode 100644 index 00000000..be4e740d --- /dev/null +++ b/packages/binden/test/fixtures/stream-like-destroyed.ts @@ -0,0 +1,176 @@ +// Run by `stream-like.test.ts`: the late error of a stream-like source without +// a listener of the application must not crash the process, also when it has +// no `destroy()` (its pause fails on the next tick, once cancelled at the +// deadline or released with a 204), and when the application has destroyed +// it (before the send: `readable-stream` 2 and 4, whose destroy fails 20 ms +// later, and a `PassThrough` of `readable-stream` 2 whose error follows on the +// next tick; or once sent, emitting its `end` at once and failing 20 ms +// later; only the cancelled one waits for a deadline): it is logged at +// `debug`, but the error of a source destroyed with it before the send that +// its state tells (its `errored`, of `readable-stream` 4), which the send +// rejects with. +import { Binden, releasedFailure } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { type IncomingMessage, request } from "node:http"; +import { capture, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { createRequire } from "node:module"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const require = createRequire(import.meta.url); + +/** A stream of `readable-stream` */ +interface IStream extends EventEmitter { + pause(): this; + resume(): this; + destroy(error?: Error): void; +} + +/** The streams of a version of `readable-stream` */ +interface ILibrary { + readonly PassThrough: new () => IStream; + readonly Readable: new (options: { + read(): void; + destroy( + this: IStream, + error: Error | null, + callback: (error: Error | null) => void, + ): void; + }) => IStream; +} + +const [v2, v4] = ["2", "4"].map( + (version) => require(`readable-stream-v${version}`) as ILibrary, +) as [ILibrary, ILibrary]; + +/** A readable whose destroy fails 20 ms later (once it has emitted its `end`) */ +function failing(library: ILibrary, ended: boolean): IStream { + return new library.Readable({ + read(): void { + // Nothing to read + }, + destroy(error, callback): void { + if (ended) { + this.emit("end"); + } + setTimeout(callback, 20, error); + }, + }); +} + +/** A source without `destroy()`, whose pause fails on the next tick */ +function pausing(): EventEmitter { + const source = Object.assign(new EventEmitter(), { + resume: (): null => null, + pause: (): null => { + process.nextTick(() => { + source.emit("error", new Error("Failed after its pause")); + }); + return null; + }, + }); + return source; +} + +/** The sources, by path */ +const sources = new Map(); +/** What each send has settled with, by path */ +const sent = new Map(); + +/** The source of a path (destroyed at once, or later, by the application) */ +function sourceOf(pathname: string): EventEmitter { + if (pathname === "/cancelled" || pathname === "/released") { + return pausing(); + } else if (pathname === "/v2" || pathname === "/v4") { + const source = failing(pathname === "/v2" ? v2 : v4, false); + source.destroy(new Error(`Failed before the send (${pathname})`)); + return source; + } else if (pathname === "/same-turn") { + const source = new v2.PassThrough(); + source.destroy(new Error("Failed in the same turn")); + return source; + } + // `/ended`: destroyed by a timer once its send has started (without its + // deadline, see below). Its premature close follows in the same turn of + // the event loop (a `setImmediate()` of the send), before any timer: the + // failure of its destroy 20 ms later never comes first, however long the + // process stalls, as it would after a destroy in a `setImmediate()` + const source = failing(v2, true); + setTimeout(() => { + source.destroy(new Error("Failed after its end")); + }, 2); + return source; +} + +const captured = capture(); +const app = new Binden({ + logger: captured.logger, + log_error_details: true, + timeout: 50, +}).use(async (context) => { + const { pathname } = context.url; + if (pathname !== "/cancelled") { + // Only `/cancelled` waits for its deadline: the others are decided + // without it, and a loaded machine could let it pass before the timer + // that destroys `/ended` (its `503` replied) + context.setTimeout(0); + } + if (pathname === "/released") { + context.status(204); + } + const source = sourceOf(pathname); + sources.set(pathname, source); + await context.send(source as IStream).then( + () => { + sent.set(pathname, "resolved"); + }, + (error: unknown) => { + const { code, message, reason } = error as { + code?: unknown; + message?: unknown; + reason?: unknown; + }; + sent.set(pathname, String(reason ?? code ?? message)); + throw error; + }, + ); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const paths = ["/cancelled", "/released", "/same-turn", "/v2", "/v4", "/ended"]; +const statuses: number[] = []; +for (const path of paths) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +/** The late errors that have been logged */ +const reported = (): string[] => + captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => String((failure as { message?: unknown }).message)); +// Of every source but `/v4`, whose error rejects its send +await pollUntil(() => reported().length >= paths.length - 1); +// Once the guards of the sources that close are removed +await new Promise((resolve) => { + setTimeout(resolve, 20); +}); +server.close(); +process.stdout.write( + `${statuses.join(" ")}\n${paths.map((path) => sent.get(path)).join(" ")}\n${reported().sort().join(", ")}\n${paths + .map((path) => sources.get(path)?.listenerCount("error")) + .join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/stream-like-ended.ts b/packages/binden/test/fixtures/stream-like-ended.ts new file mode 100644 index 00000000..4f534ba2 --- /dev/null +++ b/packages/binden/test/fixtures/stream-like-ended.ts @@ -0,0 +1,108 @@ +// Run by `stream-like.test.ts`: a source of `readable-stream` 4 destroys +// itself once it has ended (`autoDestroy`), and its destroy may fail later, +// without a listener of the application. It must not crash the process, +// whether it has ended before the send (an empty body) or while it is sent: +// its late error is logged at `debug`, and its guard is removed once it has +// closed. +import { Binden, releasedFailure } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { type IncomingMessage, request } from "node:http"; +import { capture, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { createRequire } from "node:module"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const require = createRequire(import.meta.url); + +/** A stream of `readable-stream` 4 */ +interface IStream extends EventEmitter { + pause(): this; + resume(): this; + destroy(error?: Error): void; +} + +const { Readable } = require("readable-stream-v4") as { + readonly Readable: new (options: { + read(this: IStream & { push(chunk: null): boolean }): void; + destroy(error: Error | null, callback: (error: Error | null) => void): void; + }) => IStream; +}; + +/** A readable that ends at once, whose destroy fails 20 ms later */ +function ending(pathname: string): IStream { + return new Readable({ + read(): void { + this.push(null); + }, + destroy(_error, callback): void { + setTimeout(callback, 20, new Error(`Failed after its end (${pathname})`)); + }, + }); +} + +/** The sources, by path */ +const sources = new Map(); +/** What each send has settled with, by path */ +const sent = new Map(); + +const captured = capture(); +const app = new Binden({ + logger: captured.logger, + log_error_details: true, +}).use(async (context) => { + const { pathname } = context.url; + const source = ending(pathname); + sources.set(pathname, source); + if (pathname === "/ended") { + // Ended before the send: its destroy is pending + source.resume(); + await once(source, "end"); + } + await context.send(source).then( + () => { + sent.set(pathname, "resolved"); + }, + (error: unknown) => { + sent.set(pathname, String((error as { code?: unknown }).code)); + throw error; + }, + ); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const paths = ["/ended", "/active"]; +const statuses: number[] = []; +for (const path of paths) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +/** The late errors that have been logged */ +const reported = (): string[] => + captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => String((failure as { message?: unknown }).message)); +await pollUntil(() => reported().length >= paths.length); +// Once the guards of the sources, which have closed, are removed +await new Promise((resolve) => { + setTimeout(resolve, 20); +}); +server.close(); +process.stdout.write( + `${statuses.join(" ")}\n${paths.map((path) => sent.get(path)).join(" ")}\n${reported().sort().join(", ")}\n${paths + .map((path) => sources.get(path)?.listenerCount("error")) + .join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/stream-like-errors.ts b/packages/binden/test/fixtures/stream-like-errors.ts new file mode 100644 index 00000000..652f572c --- /dev/null +++ b/packages/binden/test/fixtures/stream-like-errors.ts @@ -0,0 +1,116 @@ +// Run by `stream-like.test.ts`: the errors that a stream-like source (without +// a listener of the application) emits once it has closed, or once a +// cancellation has destroyed it, must not crash the process +import { EventEmitter, once } from "node:events"; +import { type IncomingMessage, request } from "node:http"; +import { ioTurn, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { Binden } from "../../index.js"; +import { createRequire } from "node:module"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const require = createRequire(import.meta.url); + +/** A stream of `readable-stream` */ +interface IStream extends EventEmitter { + pause(): this; + resume(): this; + destroy(error?: Error): void; +} + +/** The streams of a version of `readable-stream` */ +interface ILibrary { + readonly PassThrough: new () => IStream; + readonly Readable: new (options: { + read(): void; + destroy(error: Error | null, callback: (error: Error | null) => void): void; + }) => IStream; +} + +const [v2, v3, v4] = ["2", "3", "4"].map( + (version) => require(`readable-stream-v${version}`) as ILibrary, +) as [ILibrary, ILibrary, ILibrary]; + +/** The sources, by path */ +const sources = new Map(); + +const app = new Binden({ timeout: 50 }).use(async (context) => { + const { pathname } = context.url; + if (pathname === "/closed") { + // A `PassThrough` of `readable-stream` 2 emits its `end` and `close`, + // then the `error` of its destroy, on the turn after the request has + // started its send: without the deadline, which a loaded machine could + // pass first (its error is replied) + context.setTimeout(0); + const source = new v2.PassThrough(); + sources.set(pathname, source); + setImmediate(() => { + source.destroy(new Error("Destroyed")); + }); + await context.send(source); + } else if (pathname === "/custom") { + // A source of its own that emits `close`, then an error, on the turn + // after the request has started its send (without the deadline) + context.setTimeout(0); + const source = Object.assign(new EventEmitter(), { + pause: (): null => null, + resume: (): null => null, + }); + sources.set(pathname, source); + setImmediate(() => { + source.emit("close"); + process.nextTick(() => source.emit("error", new Error("Closed"))); + }); + await context.send(source); + } else if (pathname === "/deadline") { + // Destroyed with the reason of the deadline, which it emits + const source = new v3.PassThrough(); + sources.set(pathname, source); + await context.send(source); + } else { + // Its destroy completes later: then it emits the reason + const source = new v4.Readable({ + read(): void { + // Nothing to read + }, + destroy(error, callback): void { + setTimeout(callback, 30, error); + }, + }); + sources.set(pathname, source); + await context.send(source); + } +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const path of ["/closed", "/custom", "/deadline", "/late"]) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +// Once every source has emitted its error: the last one, of `/late`, once +// its destroy has completed (then it closes), and its guard has left (on the +// turn after its close) +const late = sources.get("/late") as IStream & { readonly closed: boolean }; +await pollUntil(() => late.closed, 5000).catch(() => null); +await ioTurn(); +server.close(); +process.stdout.write( + `${statuses.join(" ")}\n${[...sources.values()] + .map((source) => source.listenerCount("error")) + .join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/stream-like-refusals.ts b/packages/binden/test/fixtures/stream-like-refusals.ts new file mode 100644 index 00000000..a7842f90 --- /dev/null +++ b/packages/binden/test/fixtures/stream-like-refusals.ts @@ -0,0 +1,550 @@ +// Run by `guard-refusals.test.ts`, without a handler of the process: code of a +// stream-like source (an `EventEmitter` with the methods, and the `Readable` +// of `readable-stream` 2, 3 and 4) that throws while `send()` reads it, for +// each case of `cases`. Nothing may crash the process, nothing of the send may +// be left on the source but its guard (or, when the guard cannot be +// installed, the listener of `error` of the send, which logs a late error), +// and the send settles as it would with a Node.js `Readable`. One line per +// source and case: ` destroyed= listeners=/// late=`, then the counts of the records of the removals that have +// thrown, of the guards refused, of the sources left, and of the strings +// thrown that have been logged. +import { Binden, type Context, type IStreamLike } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { type IncomingMessage, request } from "node:http"; +import { capture, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { createRequire } from "node:module"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const require = createRequire(import.meta.url); + +/** A stream-like source, as the cases see it */ +interface ISource extends EventEmitter { + readonly destroyed?: boolean; + push?(chunk: unknown): boolean; + pause(): this; + resume(): this; + destroy?(error?: Error): this; +} + +/** + * A stream-like source that is not a stream: its `destroy()` emits its error + * (if any) and its `close` on the next turn + */ +class Emitter extends EventEmitter { + public destroyed = false; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: Error): this { + this.destroyed = true; + setImmediate(() => { + if (error) { + this.emit("error", error); + } + this.emit("close"); + }); + return this; + } +} + +/** The `Readable` of a version of `readable-stream` */ +type IReadable = new (options: { + autoDestroy?: boolean; + read(): void; +}) => ISource; + +/** The sources, by name */ +const libraries: Readonly ISource>> = { + emitter: () => new Emitter(), + ...Object.fromEntries( + ["2", "3", "4"].map((version) => [ + `v${version}`, + (): ISource => { + const { Readable } = require(`readable-stream-v${version}`) as { + Readable: IReadable; + }; + return new Readable({ + read(): void { + // Pushed by the cases + }, + }); + }, + ]), + ), +}; + +/** Sends its data (`hello`), then its end, on the next turn */ +function whole(source: ISource): void { + setImmediate(() => { + if (typeof source.push === "function") { + source.push("hello"); + source.push(null); + } else { + source.emit("data", Buffer.from("hello")); + source.emit("end"); + } + }); +} + +/** A method of a source, called with the source */ +type IMethod = (...args: unknown[]) => unknown; + +/** Makes the `on()` of a source throw for the listeners of `error` from the n-th (1 for every one) */ +function refusing(source: ISource, from: number): void { + const on = Reflect.get(source, "on") as IMethod; + let listeners = 0; + Object.assign(source, { + on(event: string, listener: () => void): unknown { + if (event === "error") { + listeners += 1; + if (listeners >= from) { + throw new Error(`No listener of error #${listeners}`); + } + } + return Reflect.apply(on, source, [event, listener]); + }, + }); +} + +/** Makes the `destroy()` of a source fail 20 ms later, once it is called */ +function failing(source: ISource, key: string): void { + Object.assign(source, { + destroy(): ISource { + Object.assign(source, { destroyed: true }); + setTimeout(() => { + source.emit("error", new Error(`Late failure (${key})`)); + }, 20); + return source; + }, + }); +} + +/** Makes the `removeListener()` of a source throw for these events */ +function unremovable(source: ISource, events: readonly string[]): void { + const removeListener = Reflect.get(source, "removeListener") as IMethod; + Object.assign(source, { + removeListener(event: string, listener: () => void): unknown { + if (events.includes(event)) { + throw new Error(`No removal of ${event}`); + } + return Reflect.apply(removeListener, source, [event, listener]); + }, + }); +} + +/** A case: what it does to the source before it is sent, and which sources it applies to */ +interface ICase { + readonly sources: readonly string[]; + readonly method?: string; + readonly deadline?: boolean; + /** The source, if not one of `libraries` */ + create?(): ISource; + /** What it does before the send (`key`: of the source and the case) */ + prepare(source: ISource, key: string): void; + /** + * What it does once every send has settled (and its listeners have been + * removed, or not) + */ + later?(source: ISource, key: string): void; +} + +const all = Object.keys(libraries); +/** A late error of the source, once it has been released */ +const later = (source: ISource, key: string): void => { + source.emit("error", new Error(`Late failure (${key})`)); +}; +const cases: Readonly> = { + // A listener of its `newListener` that throws for a listener of the send + ...Object.fromEntries( + ["close", "end", "data"].map((event) => [ + `newListener-${event}`, + { + sources: all, + prepare: (source: ISource): void => { + source.on("newListener", (name: string) => { + if (name === event) { + throw new Error(`newListener(${event}) refused`); + } + }); + }, + }, + ]), + ), + // Its own `on()` throws only for the listener of `error` of its guard (the + // second): the send settles by its end, or by its error, and its own + // listener of `error` reports a later error + "guard-end": { + sources: all, + prepare: (source: ISource): void => { + refusing(source, 2); + whole(source); + }, + later, + }, + "guard-error": { + sources: ["emitter"], + prepare: (source: ISource): void => { + refusing(source, 2); + setImmediate(() => { + source.emit("error", new Error("Its I/O has failed")); + }); + }, + later, + }, + // Cancelled at the deadline: destroyed without the reason, its destroy + // fails later + "guard-deadline": { + sources: all, + deadline: true, + prepare: (source: ISource, key: string): void => { + refusing(source, 2); + failing(source, key); + }, + }, + // Its own `on()` throws for every listener of `error`, and its destroy + // would fail later: it is never destroyed + every: { + sources: all, + prepare: (source: ISource, key: string): void => { + refusing(source, 1); + failing(source, key); + }, + }, + "every-head": { + sources: ["emitter"], + method: "HEAD", + prepare: (source: ISource, key: string): void => { + refusing(source, 1); + failing(source, key); + }, + }, + // Its own `removeListener()` throws: sent whole + ...Object.fromEntries( + [ + ["error", ["error"]], + ["end", ["end"]], + ["all", ["data", "end", "error", "close"]], + ].map(([name, events]) => [ + `removeListener-${name as string}`, + { + sources: all, + prepare: (source: ISource): void => { + unremovable(source, events as string[]); + whole(source); + }, + // Its listener of `error` that the send could not remove reports it + // (the guard of a source that has closed could not either) + ...(name === "end" ? {} : { later }), + }, + ]), + ), + // Its own `pause()` throws once the response is full (its first chunk is + // larger than the high water mark of the response) + pause: { + sources: ["emitter"], + prepare: (source: ISource): void => { + Object.assign(source, { + pause: (): never => { + throw new Error("pause() refused"); + }, + }); + setImmediate(() => { + source.emit("data", Buffer.alloc(1 << 20, 0x61)); + source.emit("data", Buffer.alloc(1 << 20, 0x61)); + }); + }, + }, + // Without `destroy()`: paused once failed, which throws once more + "pause-undestroyable": { + sources: ["emitter"], + prepare: (source: ISource): void => { + let pauses = 0; + Object.assign(source, { + destroy: null, + pause: (): never => { + pauses += 1; + throw new Error(`pause() refused #${pauses}`); + }, + }); + setImmediate(() => { + source.emit("data", Buffer.alloc(1 << 20, 0x61)); + source.emit("data", Buffer.alloc(1 << 20, 0x61)); + }); + }, + }, + // Its own `on()` emits an `error` once the send listens, and refuses the + // listener of `error` of its guard: the one of the send has heard it, and + // stays + "guard-emitted": { + sources: ["emitter"], + prepare: (source: ISource): void => { + const on = Reflect.get(source, "on") as IMethod; + let listeners = 0; + Object.assign(source, { + on(event: string, listener: () => void): unknown { + if (event === "error") { + listeners += 1; + if (listeners === 2) { + throw new Error("No listener of error #2"); + } + } + const result = Reflect.apply(on, source, [event, listener]); + if (event === "error") { + source.emit("error", new Error("Its I/O has failed at once")); + } + return result; + }, + }); + }, + later, + }, + // Its own `on()` throws a string (for `end`): rejected with it, and + // destroyed with it (its destroy emits it, logged once) + "on-string": { + sources: ["emitter"], + prepare: (source: ISource): void => { + const on = Reflect.get(source, "on") as IMethod; + Object.assign(source, { + on(event: string, listener: () => void): unknown { + if (event === "end") { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- a string on purpose + throw "Thrown"; + } + return Reflect.apply(on, source, [event, listener]); + }, + }); + }, + }, + // Failed without being destroyed (`errored`, also of its state); a falsy + // `errored` is no error + "errored-false": { + sources: ["emitter"], + prepare: (source: ISource): void => { + Object.assign(source, { errored: false }); + whole(source); + }, + }, + errored: { + sources: ["emitter"], + prepare: (source: ISource): void => { + Object.assign(source, { errored: new Error("Errored before") }); + }, + }, + // Of `readable-stream` 4 without `autoDestroy`, whose chunk is refused + "errored-v4": { + sources: ["v4"], + prepare: (): void => { + // Errored already (see `create()`) + }, + create: (): ISource => { + const { Readable } = require("readable-stream-v4") as { + Readable: IReadable; + }; + const source = new Readable({ + autoDestroy: false, + read(): void { + // Nothing to read + }, + }); + // Of the application, without which the error of the chunk would + // crash the process + source.on("error", () => null); + (source.push as (chunk: unknown) => boolean)(42); + return source; + }, + }, + "errored-state": { + sources: ["emitter"], + prepare: (source: ISource): void => { + Object.assign(source, { + _readableState: { errored: new Error("Errored before") }, + }); + }, + }, + // Its own `resume()` throws a falsy value + ...Object.fromEntries( + // eslint-disable-next-line no-undefined -- a thrown `undefined` + [null, undefined].map((value) => [ + `resume-${String(value)}`, + { + sources: ["emitter"], + prepare: (source: ISource): void => { + Object.assign(source, { + resume: (): never => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- a falsy value on purpose + throw value; + }, + }); + }, + }, + ]), + ), +}; + +/** How a send has rejected: the code of its error, or else its message, or the value */ +function rejection(error: unknown): string { + if (typeof error !== "object" || error === null) { + return String(error); + } + const { code, message } = error as { code?: unknown; message?: unknown }; + return typeof code === "string" ? code : String(message); +} + +/** The message of an error, or the value */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** What each source has been destroyed with, and how each send has settled, by key */ +const destroys = new Map(); +const sources = new Map(); +const outcomes = new Map(); + +const captured = capture(); +const middleware = async (context: Context): Promise => { + const [, library = "", name = ""] = context.url.pathname.split("/"); + const key = `${library} ${name}`; + const spec = cases[name]; + const source = spec.create?.() ?? libraries[library](); + sources.set(key, source); + spec.prepare(source, key); + // Every destroy, by the methods that the send reads (once prepared) + const destroy: unknown = Reflect.get(source, "destroy"); + const destroyed: unknown[] = []; + destroys.set(key, destroyed); + if (typeof destroy === "function") { + Object.assign(source, { + destroy(error?: unknown): unknown { + destroyed.push(error); + return Reflect.apply(destroy, source, [error]); + }, + }); + } + outcomes.set( + key, + await context.send(source as unknown as IStreamLike).then( + () => "resolved", + (error: unknown) => `rejected:${rejection(error)}`, + ), + ); + if (!context.response.headersSent) { + throw new Error("Replied"); + } +}; +const options = { logger: captured.logger, log_error_details: true }; +const ports: number[] = []; +for (const timeout of [0, 50]) { + const server = new Binden({ ...options, timeout }) + .use(middleware) + .createServer() + .listen(0, "127.0.0.1"); + await once(server, "listening"); + ports.push((server.address() as AddressInfo).port); +} + +/** The status of a request, `0` once the connection is closed before its end */ +function status(path: string, spec: ICase): Promise { + return new Promise((resolve) => { + request({ + agent: false, + path, + method: spec.method ?? "GET", + host: "127.0.0.1", + port: spec.deadline === true ? ports[1] : ports[0], + }) + .on("response", (response: IncomingMessage) => { + response + .resume() + .on("end", () => { + resolve(response.statusCode ?? 0); + }) + .on("error", () => { + resolve(0); + }); + }) + .on("error", () => { + resolve(0); + }) + .end(); + }); +} + +const lines: [string, number][] = []; +for (const [name, spec] of Object.entries(cases)) { + for (const library of spec.sources) { + lines.push([ + `${library} ${name}`, + await status(`/${library}/${name}`, spec), + ]); + } +} +/** Whether a late error of each of these keys has been logged */ +const reported = (keys: readonly string[]): boolean => + keys.every((key) => + captured.records.some( + ({ failure }) => + (failure as { message?: unknown } | undefined)?.message === + `Late failure (${key})`, + ), + ); +/** The keys of the sources of the cases that have a property */ +function keysOf(property: keyof ICase): string[] { + const keys: string[] = []; + for (const [name, spec] of Object.entries(cases)) { + if (property in spec) { + keys.push(...spec.sources.map((library) => `${library} ${name}`)); + } + } + return keys; +} +// The late errors of the destroys at the deadline (20 ms later), then those +// of the sources that fail once released +await pollUntil(() => reported(keysOf("deadline")), 5_000).catch(() => null); +for (const [name, spec] of Object.entries(cases)) { + for (const library of spec.sources) { + const key = `${library} ${name}`; + spec.later?.(sources.get(key) as ISource, key); + } +} +await pollUntil(() => reported(keysOf("later")), 5_000).catch(() => null); +// Anything else (which none may log), and the turn after the close of each +// source +await new Promise((resolve) => { + setTimeout(resolve, 50); +}); + +/** The messages of the errors of a source logged once released */ +const late = captured.records + .filter(({ level }) => level === 20) + .map( + ({ failure }) => (failure as { message?: unknown } | undefined)?.message, + ); +const count = (message: string): number => + late.filter((logged) => logged === message).length; +process.stdout.write( + `${lines + .map(([key, code]) => { + const source = sources.get(key) as ISource; + const destroyed = (destroys.get(key) as unknown[]).map(messageOf); + const listeners = ["data", "end", "error", "close"] + .map((event) => source.listenerCount(event)) + .join("/"); + return `${key} ${code} ${outcomes.get(key)} destroyed=${destroyed.join("|")} listeners=${listeners} late=${count(`Late failure (${key})`)}`; + }) + .join("\n")}\n` + + `removals=${late.filter((logged) => typeof logged === "string" && logged.startsWith("No removal")).length} refusals=${late.filter((logged) => typeof logged === "string" && logged.startsWith("No listener")).length} left=${captured.records.filter(({ msg }) => msg === "A stream could not be released").length} strings=${captured.records.filter(({ failure }) => (failure as { type?: unknown } | undefined)?.type === "string").length}\n`, +); +process.exit(0); diff --git a/packages/binden/test/fixtures/stream-like-release.ts b/packages/binden/test/fixtures/stream-like-release.ts new file mode 100644 index 00000000..b14ae71a --- /dev/null +++ b/packages/binden/test/fixtures/stream-like-release.ts @@ -0,0 +1,133 @@ +// Run by `stream-like.test.ts`: the late error of the destroy of a stream-like +// source that is not sent (to `HEAD`, with a 204 or a 304 status, or refused +// for an informational status) or no longer (cancelled at the deadline), +// without a listener of the application, must not crash the process: it is +// logged at `debug`, also 2 s later, and after the `close` of the source +// (within the turn). A source of an ignored send is not touched at all. +import { Binden, type IStreamLike, releasedFailure } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { type IncomingMessage, request } from "node:http"; +import { capture, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { createRequire } from "node:module"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 15_000).unref(); + +const require = createRequire(import.meta.url); + +/** A stream-like source */ +type ISource = EventEmitter & IStreamLike; + +/** A readable of `readable-stream` 4 */ +type IReadable = new (options: { + read(): void; + destroy(error: Error | null, callback: (error: Error) => void): void; +}) => ISource; + +const { Readable } = require("readable-stream-v4") as { Readable: IReadable }; + +/** A source whose destroy fails, at once or `ms` later */ +function failing(ms: number): ISource { + return new Readable({ + read(): void { + // Nothing to read + }, + destroy(_error, callback): void { + const error = new Error(`Failed to close after ${ms} ms`); + if (ms === 0) { + callback(error); + } else { + setTimeout(callback, ms, error); + } + }, + }); +} + +/** A source of its own, whose destroy emits `close`, then an error */ +function closing(): ISource { + const source = Object.assign(new EventEmitter(), { + pause: (): null => null, + resume: (): null => null, + destroy: (): void => { + source.emit("close"); + process.nextTick(() => { + source.emit("error", new Error("Failed once closed")); + }); + }, + }); + return source; +} + +const sources: ISource[] = []; +const captured = capture(); +const app = new Binden({ + logger: captured.logger, + log_error_details: true, + timeout: 50, +}).use(async (context) => { + const { pathname } = context.url; + const source = + pathname === "/closing" + ? closing() + : failing(pathname === "/later" || pathname === "/deadline" ? 2000 : 0); + sources.push(source); + if (pathname === "/ended") { + context.response.end(); + // Ignored: nothing of it is read, and it is not destroyed + await context.response.send(source); + return; + } else if ( + pathname !== "/" && + pathname !== "/later" && + pathname !== "/closing" + ) { + context.status(Number(pathname.slice(1)) || 200); + } + await context.send(source); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const [method, path] of [ + ["HEAD", "/"], + ["GET", "/204"], + ["GET", "/304"], + ["GET", "/103"], + ["GET", "/ended"], + ["HEAD", "/closing"], + ["HEAD", "/later"], + ["GET", "/deadline"], +]) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, method, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +/** The late errors that have been logged */ +const reported = (): string[] => + captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => String((failure as { message?: unknown }).message)); +// The destroys that fail 2 s later +await pollUntil(() => reported().length >= 7); +// Once their guards are removed: on the turn after their `close` +await new Promise((resolve) => { + setTimeout(resolve, 20); +}); +server.close(); +process.stdout.write( + `${statuses.join(" ")}\n${reported().join(", ")}\n${sources + .map((source) => source.listenerCount("error")) + .join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/stream-like-retention.ts b/packages/binden/test/fixtures/stream-like-retention.ts new file mode 100644 index 00000000..73b8559c --- /dev/null +++ b/packages/binden/test/fixtures/stream-like-retention.ts @@ -0,0 +1,76 @@ +// Run by `stream-like.test.ts` with `--expose-gc`: a stream-like source whose +// `destroy()` emits nothing (which its contract does not require), cancelled +// at the deadline, must not keep the response: the send removes its listeners +// at once, and the guard that stays on the source holds nothing but the +// report of a late error. Prints whether the responses are collected, and +// the listeners left on the sources (of `data`, `end`, `error` and `close`). +import { Agent, get } from "node:http"; +import { Binden, BindenResponse, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { capture } from "../helpers.js"; +import { queryObjects } from "node:v8"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +/** A stream-like source whose `destroy()` emits nothing */ +class Silent extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(): this { + return this; + } +} + +const requests = 400; +const sources: Silent[] = []; +// The report of the guards logs through it +const { logger } = capture(); +const app = new Binden({ logger, timeout: 10 }).use( + async (context: Context) => { + const source = new Silent(); + sources.push(source); + await context.send(source); + }, +); + +const server = app.createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const agent = new Agent({ keepAlive: true, maxSockets: 8 }); +const request = (): Promise => + new Promise((resolve, reject) => { + get({ port, agent, host: "127.0.0.1" }, (response) => { + response.resume().once("end", resolve); + }).once("error", reject); + }); +for (let sent = 0; sent < requests; sent += 8) { + await Promise.all(Array.from({ length: 8 }, request)); +} +agent.destroy(); +server.close(); +await once(server, "close"); +const gc = Reflect.get(globalThis, "gc") as () => void; +for (let round = 0; round < 3; round += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + gc(); +} +const left = queryObjects(BindenResponse, { format: "count" }); +const listeners = ["data", "end", "error", "close"].map((event) => + sources.reduce((count, source) => count + source.listenerCount(event), 0), +); +process.stdout.write( + `${left < requests / 10 ? "collected" : `kept ${left}`} ${listeners.join(" ")}\n`, +); diff --git a/packages/binden/test/fixtures/throwing-close.ts b/packages/binden/test/fixtures/throwing-close.ts new file mode 100644 index 00000000..957e4932 --- /dev/null +++ b/packages/binden/test/fixtures/throwing-close.ts @@ -0,0 +1,57 @@ +// Run by `process.test.ts`: a `close` listener of the application that throws +// surfaces as with Node.js (an uncaught exception, observed here), and the +// framework still completes the request, after the listeners of the +// application: its cleanup runs, and its access log is written. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; +import { pollUntil } from "../helpers.js"; + +const thrown: string[] = []; +process.on("uncaughtException", (error: Error) => { + thrown.push(error.message); +}); +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const lines: string[] = []; +const logger = new Logger({ + destination: { + write(line: string): void { + lines.push(line); + }, + }, + level: "info", +}); +const events: string[] = []; +const app = new Binden({ logger, access_log: true }).use( + async (context: Context) => { + context.onCleanup(() => { + events.push("cleanup"); + }); + context.response.on("close", () => { + events.push("listener"); + throw new Error("A close listener has failed"); + }); + await context.text("ok"); + }, +); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const socket = connect(port, "127.0.0.1").resume(); +await once(socket, "connect"); +socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); +await once(socket, "close"); +await pollUntil(() => events.includes("cleanup")); +server.close(); +const logged = lines.filter((line) => + line.includes("The request has completed"), +).length; +process.stdout.write( + `${events.join(",")} ${thrown.join(",")} logged:${logged}\n`, +); diff --git a/packages/binden/test/fixtures/throwing-late-reports.ts b/packages/binden/test/fixtures/throwing-late-reports.ts new file mode 100644 index 00000000..6508eca7 --- /dev/null +++ b/packages/binden/test/fixtures/throwing-late-reports.ts @@ -0,0 +1,105 @@ +// Run by `deferred-reports.test.ts`, without a handler of the process: a +// late error is reported on a later turn (see `reportOnce()`), where a report +// that throws (e.g. a logger of the application whose own `debug()` throws) +// has nobody to throw to. It is ignored, and never crashes the process, +// whatever reports it: a guard, the listener that a settled send keeps, the +// send itself (`reportLate()`), or the framework (a released stream-like +// source of `HEAD`, whose destroy fails later). Prints what each report has +// been given, and the status of the `HEAD`. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context, releasedFailure } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { + emitterMethods, + forwarder, + guard, + reportLate, +} from "../../src/stream-like.js"; +import type { ILogger } from "@binden/logger"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +const reported: string[] = []; + +/** A report of a late error that throws, once it has recorded the error */ +function report(error: unknown): never { + reported.push((error as Error).message); + throw new Error("The report has failed"); +} + +const guarded = new EventEmitter(); +guard(guarded, emitterMethods, report); +guarded.emit("error", new Error("guard")); +const kept = new EventEmitter(); +const listener = forwarder(() => null); +kept.on("error", listener.listener); +listener.detach(kept, report); +kept.emit("error", new Error("forwarder")); +reportLate(new EventEmitter(), new Error("reportLate"), report); + +/** Logs nothing */ +function silent(): void { + // Not logged +} + +/** A logger whose `debug()` throws for a late error, as `report` does */ +const logger: ILogger = { + fatal: silent, + error: silent, + warn: silent, + info: silent, + debug: (message: string, extra?: Record): void => { + if (message === releasedFailure) { + const failure = extra?.failure as { message?: unknown } | undefined; + reported.push(String(failure?.message)); + throw new Error("The logger has failed"); + } + }, + trace: silent, + child: (): ILogger => logger, +}; + +const app = new Binden({ logger, log_error_details: true }).use( + async (context: Context) => { + const source = Object.assign(new EventEmitter(), { + pause(): void { + // Never read + }, + resume(): void { + // Never read + }, + destroy(): void { + // Fails later, once released + setTimeout(() => { + source.emit("error", new Error("framework")); + }, 5); + }, + }); + await context.send(source); + }, +); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const socket = connect(port, "127.0.0.1"); +let received = ""; +socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; +}); +socket.write("HEAD / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"); +await once(socket, "close"); +for (let wait = 0; wait < 400 && reported.length < 4; wait += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); +} +// Its later turns +for (let turn = 0; turn < 2; turn += 1) { + await new Promise(setImmediate); +} +server.close(); +const status = /^HTTP\/1\.1 (?\d{3})/u.exec(received)?.groups?.code; +process.stdout.write(`${status ?? "none"} ${reported.join(",")}\n`); diff --git a/packages/binden/test/fixtures/throwing-logger.ts b/packages/binden/test/fixtures/throwing-logger.ts new file mode 100644 index 00000000..9e79c93b --- /dev/null +++ b/packages/binden/test/fixtures/throwing-logger.ts @@ -0,0 +1,54 @@ +// 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 { Binden } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; + +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, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const response = await new Promise((resolve, reject) => { + request({ agent: false, host: "127.0.0.1", 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/fixtures/throwing-sources.ts b/packages/binden/test/fixtures/throwing-sources.ts new file mode 100644 index 00000000..43fcf142 --- /dev/null +++ b/packages/binden/test/fixtures/throwing-sources.ts @@ -0,0 +1,242 @@ +// Run by `source-failures.test.ts`: code of a `Readable` that throws while +// `send()` reads it, in every path of the send (its own `on()`, a listener of +// its `newListener`, its own `resume()` when it is fresh, flowing or paused, +// and at a drain, paused before the send or by the backpressure of the send), +// whatever it throws (an `Error`, `null`, `undefined`, a string, a number). +// Nothing may crash the process (an uncaught exception, or an unhandled +// rejection once the response has closed), and nothing of the send may be +// left: it rejects with what has been thrown, as it is, `.started` is false +// before the headers are committed (the error is replied, and the connection +// kept), the connection is closed once they are, the source is destroyed, and +// no listener is left on it. One line per case: `/ +// started= () +// destroyed= listeners=`. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { Readable } from "node:stream"; +import { capture } from "../helpers.js"; +import { once } from "node:events"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, (error: unknown) => { + process.stderr.write(`${event} ${String(error)}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 30_000).unref(); + +/** What the code of a source throws, by name (a new `Error` for each source) */ +const values: Readonly unknown>> = { + error: () => new Error("Thrown"), + null: () => null, + // eslint-disable-next-line no-undefined -- a thrown `undefined` + undefined: () => undefined, + string: () => "Thrown", + number: () => 42, +}; + +/** A body larger than the high water mark of a socket: its write always needs a drain */ +const large = 1 << 20; + +/** + * A `Readable` whose own `resume()` throws `value` from its call `from` (the + * first is 1), and that pushes `chunk` once read (nothing by default) + */ +function resuming(value: unknown, from: number, chunk?: Buffer): Readable { + let calls = 0; + let pushed = false; + return new (class extends Readable { + public override resume(): this { + calls += 1; + if (calls >= from) { + throw value; + } + return super.resume(); + } + })({ + read(): void { + if (typeof chunk !== "undefined" && !pushed) { + pushed = true; + this.push(chunk); + } + }, + }); +} + +/** The sources of each path, which throw `value`, and what the work does before it sends one */ +const paths: Readonly< + Record Readable> +> = { + // `on("data")` of Node.js resumes it + fresh: (value) => resuming(value, 1), + flowing: (value) => { + const source = resuming(value, 2); + source.resume(); + return source; + }, + // Resumed by the send + paused: (value) => { + const source = resuming(value, 1); + source.pause(); + return source; + }, + // Its own `on()`, for the second listener of the send + on: (value) => + new (class extends Readable { + public override on(...args: Parameters): this { + const [event] = args; + if (event === "end") { + throw value; + } + return super.on(...args); + } + })({ + read(): void { + // Never read + }, + }), + // For the last listener of the send (not again, e.g. for its guard) + newListener: (value) => { + const source = new Readable({ + read(): void { + // Never read + }, + }); + let thrown = false; + source.on("newListener", (event: string) => { + if (event === "close" && !thrown) { + thrown = true; + throw value; + } + }); + return source; + }, + // Resumed by the send at the drain of the response, which the work has + // filled first (its headers are committed) + drain: (value, { response }) => { + response.write(Buffer.alloc(large, 0x61)); + if (!response.writableNeedDrain) { + // Never on any platform: the chunk is larger than the high water mark + throw new Error("The response does not need a drain"); + } + const source = resuming(value, 1); + source.pause(); + return source; + }, + // Resumed by `on("data")` of Node.js, then paused by the backpressure of + // the send, and resumed by the send at the drain + backpressure: (value) => resuming(value, 2, Buffer.alloc(large, 0x61)), +}; + +/** How a send has rejected: with what has been thrown, as it is, or else with what */ +function rejection(error: unknown, thrown: unknown): string { + return Object.is(error, thrown) ? "rejected" : `rejected(${String(error)})`; +} + +interface IOutcome { + readonly outcome: string; + readonly started: boolean; + readonly source: Readable; + readonly closed: Promise; +} + +const outcomes = new Map(); +const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + const name = context.url.pathname.slice(1); + if (name === "next") { + await context.text("next"); + return; + } + const [path = "", kind = ""] = name.split("/"); + const value = values[kind](); + const closed = once(context.response, "close"); + const source = paths[path](value, context); + const outcome = await context.send(source).then( + () => "resolved", + (error: unknown) => rejection(error, value), + ); + outcomes.set(name, { + outcome, + started: context.response.started, + source, + closed, + }); + // Replied (500) before the headers are committed, the connection closed + // once they are + throw new Error("Replied"); + }, +); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +/** + * Requests the case, then `/next` on the same connection: the status lines + * received, once the connection has closed or `/next` has its reply (never + * judged by silence) + */ +async function exchange(name: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + const { promise, resolve } = Promise.withResolvers(); + socket + .setEncoding("latin1") + .on("data", (chunk: string) => { + data += chunk; + if (data.endsWith("\r\n\r\nnext")) { + resolve("open"); + } + }) + .on("error", () => { + // Closed below + }) + .on("close", () => { + resolve("closed"); + }); + socket.write( + `GET /${name} HTTP/1.1\r\nHost: localhost\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\n\r\n`, + ); + const state = await promise; + socket.destroy(); + // What a closed connection has delivered before depends on the version of + // Node.js (e.g. 24.14.1 may reset it) + const statuses = state === "open" ? data.match(/HTTP\/1\.1 \d{3}/gu) : null; + return `${(statuses ?? []).map((line) => line.slice(9)).join("+")}(${state})`; +} + +/** Resolves once the source has closed, and its guard is removed (on the turn after) */ +async function released(source: Readable): Promise { + if (!source.closed) { + await once(source, "close"); + } + await new Promise(setImmediate); + await new Promise(setImmediate); +} + +const lines: string[] = []; +for (const path of Object.keys(paths)) { + for (const kind of Object.keys(values)) { + const name = `${path}/${kind}`; + const reply = await exchange(name); + const { outcome, started, source, closed } = outcomes.get(name) as IOutcome; + // Its response has closed (a `done` left without a handler would reject + // then), and its source too + await closed; + await released(source); + const listeners = ["data", "end", "error", "close"].reduce( + (count, event) => count + source.listenerCount(event), + 0, + ); + lines.push( + `${name} ${outcome} started=${String(started)} ${reply} destroyed=${String(source.destroyed)} listeners=${String(listeners)}`, + ); + } +} +server.close(); +await once(server, "close"); +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/throwing-stream-like-logs.ts b/packages/binden/test/fixtures/throwing-stream-like-logs.ts new file mode 100644 index 00000000..c833be45 --- /dev/null +++ b/packages/binden/test/fixtures/throwing-stream-like-logs.ts @@ -0,0 +1,52 @@ +// Run by `throwing-stream-like-logs.test.ts`: a log of the release of a +// stream-like source that throws (e.g. a logger of the application whose own +// `debug()` throws) must not crash the process from a turn of its own: here, +// the removal of the listener of `error` of the send, deferred to the turn +// after the end of the source, throws, and so does its log. Prints whether +// the readable has ended. +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../../src/stream-like.js"; +import { EventEmitter } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const source = Object.assign(new EventEmitter(), { + pause(): void { + // Never full + }, + resume(): void { + // Emits by the fixture + }, + removeListener(event: string, listener: () => void): unknown { + EventEmitter.prototype.removeListener.call(source, event, listener); + if (event === "error") { + throw new Error("No removal"); + } + return source; + }, +}); +const thrown = (): never => { + throw new Error("The logger has failed"); +}; +const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + () => null, + { released: thrown, removed: thrown }, +); +readable.resume(); +source.emit("end"); +for (let turn = 0; turn < 3; turn += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} +process.stdout.write(`ended ${String(readable.readableEnded)}\n`); diff --git a/packages/binden/test/fixtures/unguarded-sources.ts b/packages/binden/test/fixtures/unguarded-sources.ts new file mode 100644 index 00000000..c3b108e6 --- /dev/null +++ b/packages/binden/test/fixtures/unguarded-sources.ts @@ -0,0 +1,97 @@ +// Run by `unguarded-sources.test.ts`, without a handler of the process: a +// stream-like source that refuses every listener of `error` (a listener of +// its `newListener` throws), whose `pause()` and `destroy()` would emit an +// error asynchronously, must be left untouched, whether it is sent (`GET`) +// or released (to `HEAD`, with 204, or refused for 103): neither paused nor +// destroyed, or the process would crash. Prints, by request, its status and +// how its send has settled, then the calls of the code of the sources, and +// the records that tell that a source could not be released. +import { Binden, type Context } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { capture } from "../helpers.js"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +/** The calls of the code of every source */ +const calls: string[] = []; + +/** A source whose `pause()` and `destroy()` fail asynchronously, which refuses every listener of `error` */ +function unguardable(): EventEmitter { + const source = new EventEmitter(); + source.on("newListener", (event: string) => { + if (event === "error") { + throw new Error("No listener of error"); + } + }); + const failing = + (name: string): (() => void) => + (): void => { + calls.push(name); + setImmediate(() => { + source.emit("error", new Error(`Its ${name} has failed`)); + }); + }; + return Object.assign(source, { + pause: failing("pause"), + resume: (): null => null, + destroy: failing("destroy"), + }); +} + +const outcomes: string[] = []; +const captured = capture(); +const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + const status = Number(context.url.pathname.slice(1)) || 200; + const outcome = await context + .status(status) + // @ts-expect-error -- a stream-like source + .send(unguardable()) + .then( + () => "resolved", + (error: unknown) => `rejected:${(error as Error).message}`, + ); + outcomes.push(outcome); + if (outcome !== "resolved") { + context.status(500); + await context.send(outcome); + } + }, +); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const lines: string[] = []; +for (const [method, path] of [ + ["GET", "/"], + ["HEAD", "/"], + ["GET", "/204"], + ["GET", "/103"], +]) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, method, path, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + lines.push(`${method} ${path} ${response.statusCode} ${outcomes.at(-1)}`); +} +// The asynchronous failures that a pause or a destroy would have caused +await new Promise((resolve) => { + setTimeout(resolve, 20); +}); +server.close(); +const left = captured.records.filter( + ({ msg }) => msg === "A stream could not be released", +).length; +process.stdout.write( + `${lines.join("\n")}\ncalls=${calls.join(",") || "none"} left=${left}\n`, +); diff --git a/packages/binden/test/fixtures/unreleased-readables.ts b/packages/binden/test/fixtures/unreleased-readables.ts new file mode 100644 index 00000000..e968619c --- /dev/null +++ b/packages/binden/test/fixtures/unreleased-readables.ts @@ -0,0 +1,195 @@ +// Run by `unreleased-readables.test.ts`, without a handler of the process: a +// Node.js `Readable` that refuses every listener of `error` (a listener of +// its `newListener` throws), with listeners of the application of its +// `pause` and its `resume` that fail it later (an `error` on the next turn), +// sent (fresh, or paused by the application), released to `HEAD` or for an +// informational status, or ignored once the response has ended. No listener +// of its `error` can be in place: binden must leave it untouched, never +// paused nor resumed (also by the `resume()` that `on("data")` of Node.js +// calls), since its error would crash the process. One line per path: ` +// pauses= resumes= flowing= +// destroyed= listeners=///`, then the +// count of the records of the sources left. Then one that refuses only the +// listener of its guard (`/listened`), which binden pauses while its send +// listens (a chunk larger than the buffer of the response), and which fails +// 100 ms later, once the deadline has cut the send: the listener of its send +// stays, and logs that error once (` pauses= +// destroyed= listeners=<...> late=`). +import { Binden, type Context } from "../../index.js"; +import { type IncomingMessage, type Server, request } from "node:http"; +import { capture, ioTurn, pollUntil } from "../helpers.js"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +/** What each source has emitted to the listeners of the application */ +interface IEvents { + pauses: number; + resumes: number; +} + +/** The sources of the paths, with their events */ +const sources = new Map(); +/** How the sends have settled, by path */ +const outcomes = new Map(); + +/** + * A `Readable` that refuses every listener of `error`, never ends, and fails + * on the turn after each `pause` that it emits (`paused`: by the application + * first, before the listeners of the application are added). `/listened` + * refuses only the second one (of its guard), pushes 64 KiB once read, and + * fails 100 ms after its pause. + */ +function refusing(path: string, paused: boolean): Readable { + const listened = path === "/listened"; + let pushed = false; + const source = new Readable({ + read(): void { + // Never ends + if (listened && !pushed) { + pushed = true; + this.push(Buffer.alloc(64 * 1024)); + } + }, + }); + if (paused) { + source.pause(); + } + const events: IEvents = { pauses: 0, resumes: 0 }; + let errors = 0; + source.on("newListener", (event: string) => { + if (event === "error") { + errors += 1; + if (!listened || errors === 2) { + throw new Error(`No listener of error (${path})`); + } + } + }); + source.on("pause", () => { + events.pauses += 1; + setTimeout( + () => { + source.emit("error", new Error(`Failed after a pause (${path})`)); + }, + listened ? 100 : 0, + ); + }); + source.on("resume", () => { + events.resumes += 1; + if (!listened) { + setImmediate(() => { + source.emit("error", new Error(`Failed after a resume (${path})`)); + }); + } + }); + sources.set(path, { source, events }); + return source; +} + +/** How a send has settled: resolved, or the message of its error */ +function outcomeOf(sent: Promise): Promise { + return sent.then( + () => "resolved", + (error: unknown) => `rejected:${(error as Error).message}`, + ); +} + +const captured = capture(); +const middleware = async (context: Context): Promise => { + const { pathname } = context.url; + const source = refusing(pathname, pathname === "/paused"); + if (pathname === "/ignored") { + context.response.end("ended"); + } else if (pathname === "/informational") { + context.response.status(103); + } + outcomes.set(pathname, await outcomeOf(context.send(source))); + if (!context.response.writableEnded) { + throw new Error("Replied"); + } +}; +const options = { logger: captured.logger, log_error_details: true }; +const servers: Server[] = []; +const ports: number[] = []; +for (const timeout of [0, 50]) { + const app = new Binden({ ...options, timeout }).use(middleware); + const server = app.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + servers.push(server); + ports.push((server.address() as AddressInfo).port); +} + +/** A request of a path (`HEAD` for `/head`), once its response has closed */ +async function requested(path: string): Promise { + const port = path === "/listened" ? ports[1] : ports[0]; + const method = path === "/head" ? "HEAD" : "GET"; + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, method, host: "127.0.0.1", port }, resolve) + .on("error", reject) + .end(); + }); + // Cut by the deadline (`/listened`): it closes without its end (with an + // `error`, which `once()` would reject with) + await new Promise((resolve) => { + response + .on("error", () => null) + .on("close", resolve) + .resume(); + }); + return response.statusCode ?? 0; +} + +const paths = ["/send", "/paused", "/head", "/ignored", "/informational"]; +const lines: string[] = []; +for (const path of paths) { + const status = await requested(path); + // A pause of binden would fail the source on the next turn + await ioTurn(); + const { source, events } = sources.get(path) as { + source: Readable; + events: IEvents; + }; + const listeners = ["data", "end", "error", "close"] + .map((event) => source.listenerCount(event)) + .join("/"); + lines.push( + `${path} ${status} ${outcomes.get(path)} pauses=${events.pauses} resumes=${events.resumes} flowing=${source.readableFlowing} destroyed=${source.destroyed} listeners=${listeners}`, + ); +} +const left = captured.records.filter( + ({ msg }) => msg === "A stream could not be released", +).length; +lines.push(`left=${left}`); + +const status = await requested("/listened"); +/** The late errors of `/listened` that have been logged */ +const late = (): number => + captured.records.filter( + ({ msg, failure }) => + msg === "A released stream has failed" && + (failure as { message?: unknown } | undefined)?.message === + "Failed after a pause (/listened)", + ).length; +// Its error (100 ms after its pause), then a while for any other report +await pollUntil(() => late() > 0, 5_000).catch(() => null); +await ioTurn(); +const { source, events } = sources.get("/listened") as { + source: Readable; + events: IEvents; +}; +const listeners = ["data", "end", "error", "close"] + .map((event) => source.listenerCount(event)) + .join("/"); +lines.push( + `/listened ${status} ${outcomes.get("/listened")} pauses=${events.pauses} destroyed=${source.destroyed} listeners=${listeners} late=${late()}`, +); +for (const server of servers) { + server.close(); +} +process.stdout.write(`${lines.join("\n")}\n`); +process.exit(0); diff --git a/packages/binden/test/fixtures/unstackable-sources.ts b/packages/binden/test/fixtures/unstackable-sources.ts new file mode 100644 index 00000000..d7b70735 --- /dev/null +++ b/packages/binden/test/fixtures/unstackable-sources.ts @@ -0,0 +1,233 @@ +// Run by `stream-like-release-failures.test.ts` and `stream-like-safe-stacks.test.ts`, +// without a handler of the process: code of a stream-like source that throws +// a value whose `stack` Node.js cannot read safely (which it reads once a +// stream is destroyed with it, and again once that stream has called back): +// a `stack` getter that throws, once its listeners are added (`/setup`) or in +// its `pause()` once the response is full (`/pause`), or a getter that +// returns a string on its first read and throws on the second, emitted +// (`/emit-once`) or thrown by its `pause()` (`/pause-once`), or an error that +// it emits (`/pst-emit`), or a chunk that is not bytes (`/pst-chunk`), while +// a `prepareStackTrace` of the application throws, or a `stack` getter that +// throws, thrown by its `resume()` (`/resume`, `/resume-once` for one that +// throws from its second read), also once the readable has room again +// (`/drain`), or by a getter of its own `destroyed` once it ends (`/end`) or +// closes (`/close`), must reject the send (with +// that value: `original`), release its listeners, throw nothing into its +// events (`threw=`), and leave its guard, so that a later error of the source +// is logged and does not crash the process. Prints, by path (the arguments, +// `/setup` and `/pause` by default), the status, how the send has settled, +// what its events have thrown, and the late errors logged. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context, releasedFailure } from "../../index.js"; +import { EventEmitter, once } from "node:events"; +import { capture, pollUntil } from "../helpers.js"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +/** An error whose `stack` getter throws, from its first read or its second */ +function unstackable(first = true): Error { + const error = new Error("Unstackable"); + let reads = 0; + Object.defineProperty(error, "stack", { + get(): string { + reads += 1; + if (first || reads > 1) { + throw new Error("Its stack cannot be read"); + } + return "Error: Unstackable"; + }, + }); + return error; +} + +/** Whether the `prepareStackTrace` of the application throws */ +let failing = false; +const formatter: unknown = Reflect.get(Error, "prepareStackTrace"); +Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): unknown => { + if (failing) { + throw new Error("The stack cannot be formatted"); + } + return typeof formatter === "function" + ? Reflect.apply(formatter, Error, [error, frames]) + : [`${error.name}: ${error.message}`, ...frames.map(String)].join( + "\n at ", + ); + }, +); + +/** A stream-like source of its own whose code throws, or that fails, by its path */ +class Source extends EventEmitter { + /** Where its code throws (see the paths above) */ + readonly #where: string; + /** What it throws, or emits */ + public readonly thrown: unknown; + /** What its events have thrown into the code that emits them */ + public readonly threw: unknown[] = []; + /** How many times it has been resumed */ + #resumes = 0; + /** Whether its `destroyed` getter throws */ + #armed = false; + + public constructor(where: string) { + super(); + this.#where = where; + this.thrown = where.startsWith("/pst") + ? new Error("Failed") + : unstackable(!where.endsWith("-once")); + } + + public override on( + event: string, + listener: (...args: unknown[]) => void, + ): this { + if (this.#where === "/setup" && event === "end") { + throw this.thrown; + } + return super.on(event, listener); + } + + public pause(): this { + if (this.#where.startsWith("/pause")) { + throw this.thrown; + } + return this; + } + + public resume(): this { + this.#resumes += 1; + if ( + this.#where.startsWith("/resume") || + (this.#where === "/drain" && this.#resumes > 1) + ) { + throw this.thrown; + } else if (this.#where === "/drain") { + // More than the readable takes: it is read once the response drains + setImmediate(() => { + for (let chunk = 0; chunk < 8; chunk += 1) { + this.fire("data", Buffer.alloc(262_144, 0x61)); + } + }); + } + return this; + } + + public destroy(): this { + return this; + } + + public get destroyed(): boolean { + if (this.#armed) { + throw this.thrown; + } + return false; + } + + /** Ends or closes, once its `destroyed` getter throws */ + public stop(event: string): void { + this.#armed = true; + this.fire(event, null); + } + + /** Emits an event, recording what it throws (`failing`: while a `prepareStackTrace` throws) */ + public fire(event: string, value: unknown, failed = false): void { + failing = failed; + try { + this.emit(event, value); + } catch (error: unknown) { + this.threw.push(error); + } finally { + failing = false; + } + } +} + +const sources = new Map(); +const outcomes = new Map(); +const captured = capture(); +const app = new Binden({ + logger: captured.logger, + log_error_details: true, +}).use(async (context: Context) => { + const { pathname } = context.url; + const source = new Source(pathname); + sources.set(pathname, source); + // Until the response is full, and then the readable of the send + let chunks = 0; + const timer = setInterval(() => { + chunks += 1; + if (pathname === "/emit-once") { + clearInterval(timer); + source.fire("error", source.thrown); + } else if (pathname === "/pst-emit") { + clearInterval(timer); + source.fire("error", source.thrown, true); + } else if (pathname === "/pst-chunk") { + clearInterval(timer); + source.fire("data", null, true); + } else if (pathname === "/end" || pathname === "/close") { + clearInterval(timer); + source.stop(pathname.slice(1)); + } else if (!pathname.startsWith("/pause") || chunks > 2000) { + clearInterval(timer); + } else { + source.fire("data", Buffer.alloc(65_536, 0x61)); + } + }, 1); + const outcome = await context.send(source).then( + () => "resolved", + (error: unknown) => + `rejected:${error === source.thrown ? "original" : (error as Error).message}`, + ); + clearInterval(timer); + outcomes.set(pathname, outcome); + throw new Error(outcome); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const lines: string[] = []; +const paths = process.argv.slice(2); +for (const path of paths.length === 0 ? ["/setup", "/pause"] : paths) { + const socket = connect(port, "127.0.0.1"); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + socket.on("error", () => null); + // Before anything is read (e.g. a reply that comes before the send has + // settled for the fixture, once the client reads) + const closed = once(socket, "close"); + socket.write(`GET ${path} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`); + if (path.startsWith("/pause")) { + // The client reads nothing until the send has settled: the response + // fills (but once the readable has room again, `/drain`) + socket.pause(); + } + await pollUntil(() => outcomes.has(path)); + socket.resume(); + await closed; + const source = sources.get(path) as Source; + const before = captured.records.length; + source.emit("error", new Error("Later")); + // Reported on a later turn + await new Promise(setImmediate); + const late = captured.records + .slice(before) + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); + const status = /^HTTP\/1\.1 (?\d{3})/u.exec(received)?.groups?.code; + const threw = source.threw.map((error) => (error as Error).message); + lines.push( + `${path} ${status ?? "none"} ${outcomes.get(path)} threw=${threw.join(",")} late=${late.join(",")}`, + ); +} +server.close(); +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/packages/binden/test/fixtures/view-getters.ts b/packages/binden/test/fixtures/view-getters.ts new file mode 100644 index 00000000..d8ac00fb --- /dev/null +++ b/packages/binden/test/fixtures/view-getters.ts @@ -0,0 +1,122 @@ +// Run by `view-getters.test.ts`, without a handler of the process: an +// object-mode `Readable` whose chunk is a view with getters of its own, which +// binden never reads (it reads the getters of the engine, and writes a +// `Buffer` of its own). `detaching`: a `Uint8Array` after a valid chunk, whose +// own `byteLength` getter detaches its buffer (Node.js would write the detached +// view as it uncorks the connection on the next tick, out of any call, and +// crash the process); `lying`: a first chunk of a subclass whose `buffer`, +// `byteOffset` and `byteLength` getters lie (a detached `ArrayBuffer`, 99 and +// 99). The send must resolve with the bytes of the views, none of those +// getters read, and the pipelined request must be answered. Prints the +// outcome. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "../../index.js"; +import { HOST } from "../helpers.js"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [mode = "detaching"] = process.argv.slice(2); + +/** How many times a getter of the application has been read */ +let reads = 0; + +/** A subclass whose own getters lie about where its bytes are (`lying`) */ +class Lying extends Uint8Array {} +Object.defineProperties(Lying.prototype, { + buffer: { + get(): ArrayBuffer { + reads += 1; + const gone = new ArrayBuffer(8); + gone.transfer(); + return gone; + }, + }, + byteOffset: { + get(): number { + reads += 1; + return 99; + }, + }, + byteLength: { + get(): number { + reads += 1; + return 99; + }, + }, +}); + +/** The chunks of the source, by the mode */ +function chunks(): Uint8Array[] { + if (mode === "lying") { + return [new Lying([0x6f, 0x6b])]; + } + const bytes = new Uint8Array([0x61]); + Object.defineProperty(bytes, "byteLength", { + get(): number { + reads += 1; + bytes.buffer.transfer(); + return 0; + }, + }); + return [Buffer.from("start"), bytes]; +} + +let outcome = "pending"; +const app = new Binden().use(async (context: Context) => { + if (context.url.pathname === "/second") { + await context.send("second"); + return; + } + try { + await context.send(Readable.from(chunks(), { objectMode: true })); + outcome = "resolved"; + } catch (error: unknown) { + outcome = `${(error as Error).constructor.name}: ${(error as Error).message}`; + throw error; + } +}); +const server = app.createServer().listen(0, HOST); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const socket = connect(port, HOST); +let received = ""; +socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; +}); +socket.on("error", () => { + // E.g. a reset of a cut response +}); +await once(socket, "connect"); +socket.write( + `GET / HTTP/1.1\r\nHost: ${HOST}\r\n\r\nGET /second HTTP/1.1\r\nHost: ${HOST}\r\nConnection: close\r\n\r\n`, +); +await once(socket, "close"); +server.close(); +/** The body of a chunked response, whatever its chunks (Node.js 26 may write two in one) */ +function dechunk(text: string): string { + let body = ""; + let rest = text; + for (;;) { + const { groups: { line = "", digits = "" } = {} } = + /^(?(?[\da-f]+)\r\n)/u.exec(rest) ?? {}; + const size = Number.parseInt(digits, 16); + if (!(size > 0)) { + return body; + } + body += rest.slice(line.length, line.length + size); + rest = rest.slice(line.length + size + 2); + } +} + +const { groups: { first = "", second = "" } = {} } = + /^HTTP\/1\.1 200 OK\r\n[^]*?\r\n\r\n(?[^]*?)HTTP\/1\.1 200 OK\r\n[^]*?\r\n\r\n(?[^]*)$/u.exec( + received, + ) ?? {}; +process.stdout.write( + `${JSON.stringify({ send: outcome, reads, body: dechunk(first), second })}\n`, +); diff --git a/packages/binden/test/formatted-overrides.test.ts b/packages/binden/test/formatted-overrides.test.ts new file mode 100644 index 00000000..f037922a --- /dev/null +++ b/packages/binden/test/formatted-overrides.test.ts @@ -0,0 +1,636 @@ +import { + BindenResponse, + type IResponseOperation, + kListen, + kNegotiator, + kOperation, + kRepresentation, + kRestoreFraming, + kValidateHead, + validateResponseOptions, +} from "../index.js"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { deepStrictEqual, ok, strictEqual } from "node:assert/strict"; +import { kFramework, kRevoke } from "../src/seal.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** An error of the application's own (e.g. of a getter, or of a trap) */ +class OwnError extends Error {} + +/** What a call has thrown (or rejected with), and whether it was formatted then */ +interface IOutcome { + readonly thrown: unknown; + readonly formatted: boolean; +} + +/** + * Runs a call while `Error.prepareStackTrace` records each error whose stack + * V8 formats: what it throws (or its promise rejects with), and whether that + * was formatted before the caller got it (`null` when it does not fail) + */ +async function outcome(run: () => unknown): Promise { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + await run(); + return null; + } catch (thrown: unknown) { + return { + thrown, + formatted: typeof thrown === "object" && formatted.has(thrown as object), + }; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A response to an HTTP/1.1 request, without a connection */ +function detached(): BindenResponse { + const request = new IncomingMessage(new Socket()); + request.httpVersionMajor = 1; + request.httpVersionMinor = 1; + return new BindenResponse(request); +} + +/** A revoked proxy */ +function revoked(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** A revoked proxy of a function */ +function revokedFunction(): unknown { + const { proxy, revoke } = Proxy.revocable(() => null, {}); + revoke(); + return proxy; +} + +/** A proxy whose every trap throws `error` */ +function throwing(error: Error): object { + const trap = (): never => { + throw error; + }; + return new Proxy( + {}, + { + get: trap, + getOwnPropertyDescriptor: trap, + getPrototypeOf: trap, + has: trap, + ownKeys: trap, + }, + ); +} + +/** A function that does nothing */ +function noop(): void { + // Nothing +} + +/** The calls whose errors (of Node.js or of V8) binden must format */ +const failures: [string, (response: BindenResponse) => unknown][] = [ + [ + "rawListeners()", + (r): unknown => r.rawListeners(Object.create(null) as never), + ], + ["emit()", (r): unknown => r.emit(Object.create(null) as never)], + [ + "on() of a listener that is not one", + (r): unknown => r.on("x", null as never), + ], + ["once() of an event name", (r): unknown => r.once(revoked() as never, noop)], + ["addListener()", (r): unknown => r.addListener("x", Symbol("s") as never)], + ["prependListener()", (r): unknown => r.prependListener("x", {} as never)], + [ + "prependOnceListener()", + (r): unknown => r.prependOnceListener("x", 1 as never), + ], + ["writeHead()", (r): unknown => r.writeHead(Symbol("s") as never)], + [ + "writeHead() of headers", + (r): unknown => r.writeHead(200, revoked() as never), + ], + [ + "writeHeader()", + (r): unknown => r.writeHeader(Object.create(null) as never), + ], + ["write()", (r): unknown => r.write(null)], + ["end()", (r): unknown => r.end({})], + ["setHeader()", (r): unknown => r.setHeader("x-a", Symbol("s") as never)], + ["getHeader()", (r): unknown => r.getHeader(null as never)], + ["appendHeader()", (r): unknown => r.appendHeader("x a", "b")], + ["setHeaders()", (r): unknown => r.setHeaders({} as never)], + [ + "setTimeout()", + (r): unknown => { + r.assignSocket(new Socket()); + return r.setTimeout("1" as never); + }, + ], + [ + "removeHeader()", + (r): unknown => { + r.removeHeader(null as never); + return null; + }, + ], + [ + "addTrailers()", + (r): unknown => { + r.addTrailers(null as never); + return null; + }, + ], + [ + "destroy()", + (r): unknown => { + r.assignSocket(new Socket()); + return r.destroy(revoked() as Error); + }, + ], + [ + "writeEarlyHints()", + (r): unknown => { + r.writeEarlyHints(null as never); + return null; + }, + ], + [ + "[kValidateHead]()", + (r): unknown => { + r[kValidateHead](200, "OK", { "x a": "b" }); + return null; + }, + ], + [ + "[kRestoreFraming]()", + (r): unknown => { + r[kRestoreFraming](null as never); + return null; + }, + ], + ["[kFramework]()", (r): unknown => r[kFramework](null as never)], + [ + "run() of [kOperation]()", + (r): unknown => (r[kOperation]() as IResponseOperation).run(5 as never), + ], + [ + "run() of [kOperation]() of a revoked function", + (r): unknown => + (r[kOperation]() as IResponseOperation).run(revokedFunction() as never), + ], + [ + "[kNegotiator]() of no choose()", + (r): unknown => { + r[kNegotiator]({} as never); + return null; + }, + ], + [ + "[kNegotiator]() of a revoked proxy", + (r): unknown => { + r[kNegotiator](revoked() as never); + return null; + }, + ], + ["[kRevoke]()", (r): unknown => r[kRevoke](revoked() as never, null)], + [ + "[kListen]() of options", + (r): unknown => r[kListen]("close", noop, null as never), + ], + // Rejected + ["send()", (r): unknown => r.status(200).send(revoked() as never)], +]; + +suite("The methods that binden defines over those of Node.js", () => { + test("format what Node.js or V8 throws for their arguments (round 27)", async () => { + for (const [label, run] of failures) { + const failed = await outcome(() => run(detached())); + ok(failed !== null, label); + ok(failed.thrown instanceof TypeError, label); + ok(failed.formatted, label); + } + }); + + test("format what the application throws through them, which is passed on as it is", async () => { + const calls: [string, (r: BindenResponse, own: Error) => unknown][] = [ + [ + "setHeader()", + (r, own): unknown => r.setHeader("x-a", throwing(own) as never), + ], + [ + "writeHead() of a cookie", + (r, own): unknown => { + r.cookies.add({ + toString(): string { + throw own; + }, + } as never); + return r.writeHead(200); + }, + ], + [ + "[kRevoke]() of a failure", + (r, own): unknown => r[kRevoke]([], throwing(own) as never), + ], + [ + "sendFile() of options", + (r, own): unknown => r.sendFile(join(tmpdir(), "a"), throwing(own)), + ], + [ + "sendFile() of a path", + (r, own): unknown => r.sendFile(throwing(own) as never), + ], + [ + "run() of [kOperation]() of a callback", + (r, own): unknown => + (r[kOperation]() as IResponseOperation).run(() => { + throw own; + }), + ], + [ + "transfer() of [kOperation]() of a writableEnded of its own", + (r, own): unknown => { + Object.defineProperty(r, "writableEnded", { + get(): never { + throw own; + }, + }); + (r[kOperation]() as IResponseOperation).transfer(); + return null; + }, + ], + [ + "[kNegotiator]() of a negotiator", + (r, own): unknown => { + r[kNegotiator](throwing(own) as never); + return null; + }, + ], + [ + "[kRepresentation]() of the choose() of the negotiator", + (r, own): unknown => { + r[kNegotiator]({ + choose(): never { + throw own; + }, + forget: noop, + }); + return r[kRepresentation](1); + }, + ], + ]; + for (const [label, run] of calls) { + // A fresh error each time: V8 formats a stack once + const own = new OwnError("own"); + deepStrictEqual( + await outcome(() => run(detached(), own)), + { thrown: own, formatted: true }, + label, + ); + } + }); + + test("format what reading the request of the response throws through writeContinue(), writeProcessing(), writeEarlyHints() and writeInformation(), which is passed on as it is", async () => { + const calls: Record unknown> = { + writeContinue: (response) => { + response.writeContinue(); + }, + writeProcessing: (response) => { + response.writeProcessing(); + }, + writeEarlyHints: (response) => { + response.writeEarlyHints({ link: "; rel=preload" }); + }, + writeInformation: (response) => { + response.writeInformation(103); + }, + }; + for (const [name, call] of Object.entries(calls)) { + // A fresh error each time: V8 formats a stack once + const own = new OwnError("own"); + let armed = false; + // Whose getters throw once the response has been made + const request = new Proxy(new IncomingMessage(new Socket()), { + get(target, key): unknown { + if (armed) { + throw own; + } + const value: unknown = Reflect.get(target, key, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const response = new BindenResponse(request); + armed = true; + deepStrictEqual( + await outcome(() => call(response)), + { thrown: own, formatted: true }, + name, + ); + } + }); + + test("format what an override of a subclass throws through flushHeaders() and writeHeader()", async () => { + let own = new OwnError("own"); + class Subclass extends BindenResponse { + // eslint-disable-next-line class-methods-use-this -- throws + public override writeHead(): this { + throw own; + } + } + for (const run of [ + (response: BindenResponse): unknown => { + response.flushHeaders(); + return null; + }, + (response: BindenResponse): unknown => response.writeHeader(200), + ]) { + own = new OwnError("own"); + deepStrictEqual( + await outcome(() => + run(new Subclass(new IncomingMessage(new Socket()))), + ), + { thrown: own, formatted: true }, + ); + } + }); + + test("format what an override of a subclass throws through send(), text() and the like", async () => { + let own = new OwnError("own"); + class Subclass extends BindenResponse { + public override setHeader(name: string, value: number | string): this { + if (name === "Content-Type") { + throw own; + } + return super.setHeader(name, value); + } + } + for (const run of [ + (response: BindenResponse): unknown => response.send("x"), + (response: BindenResponse): unknown => response.text("x"), + (response: BindenResponse): unknown => response.json({}), + ]) { + own = new OwnError("own"); + deepStrictEqual( + await outcome(() => + run(new Subclass(new IncomingMessage(new Socket()))), + ), + { thrown: own, formatted: true }, + ); + } + }); + + test("sendFile() reads its path and its options inside the formatting, and refuses an option of another type without converting it", async () => { + let conversions = 0; + const counting = { + toString(): string { + conversions += 1; + return "no-cache"; + }, + }; + const file = join(tmpdir(), "a"); + for (const options of [ + { cache_control: counting }, + { validator: counting }, + ]) { + const failed = await outcome(() => + detached().sendFile(file, options as never), + ); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + } + deepStrictEqual(conversions, 0); + // A getter of a subclass of `URL`, and a revoked `root` + const own = new OwnError("own"); + class Hostile extends URL { + // eslint-disable-next-line class-methods-use-this -- throws + public override get protocol(): string { + throw own; + } + } + deepStrictEqual( + await outcome(() => detached().sendFile(new Hostile("file:///a"))), + { thrown: own, formatted: true }, + ); + const failed = await outcome(() => + detached().sendFile("a", { root: revoked() as never }), + ); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + }); + + test("validateResponseOptions() refuses anything but an object, and formats what reading the options throws", async () => { + for (const [given, message] of [ + [null, "`options` is not an object: null"], + [5, "`options` is not an object: 5"], + ] as const) { + const failed = await outcome(() => + validateResponseOptions(given as never), + ); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + [message, true], + ); + } + const own = new OwnError("own"); + deepStrictEqual( + await outcome(() => validateResponseOptions(throwing(own) as never)), + { thrown: own, formatted: true }, + ); + deepStrictEqual(validateResponseOptions({ etag: true }), { etag: true }); + }); + + test("a Content-Length of another type (an element, also a hole) refuses send() of a stream, the stream-like and to HEAD before anything is sent: never converted, the head not committed, the source released", async () => { + let conversions = 0; + const counting = { + toString(): string { + conversions += 1; + return "2"; + }, + }; + const sparse = ["2"]; + sparse[2] = "2"; + const cases: [unknown, string][] = [ + [counting, "(an object)"], + [true, "true"], + [null, "null"], + [2n, "2n"], + [[counting], "(an object)"], + [sparse, "undefined"], + [["2", Reflect.get({}, "absent")], "undefined"], + ]; + for (const [value, described] of cases) { + for (const kind of ["readable", "stream-like", "head"]) { + const response = + kind === "head" + ? new BindenResponse( + Object.assign(new IncomingMessage(new Socket()), { + httpVersionMajor: 1, + httpVersionMinor: 1, + method: "HEAD", + }), + ) + : detached(); + response.setHeader("Content-Length", value as never); + const before = response.getHeader("Content-Length"); + // Converted by Node.js as it checks the value: not by binden + conversions = 0; + const source = new Readable({ + read(): void { + // Never + }, + }); + let paused = 0; + const streamLike = { + on: (): unknown => streamLike, + pause: (): void => { + paused += 1; + }, + resume: (): void => { + // Never read + }, + removeListener: (): unknown => streamLike, + }; + const failed = await outcome(() => + response.send(kind === "stream-like" ? streamLike : source), + ); + ok(failed?.thrown instanceof TypeError, `${described} ${kind}`); + deepStrictEqual( + [ + failed.thrown.message, + (failed.thrown as { code?: unknown }).code, + failed.formatted, + conversions, + response.headersSent, + response.getHeader("Content-Length"), + // Released (see the informational status) + kind === "stream-like" ? paused > 0 : source.destroyed, + ], + [ + `Invalid Content-Length: ${described}`, + "ERR_HTTP_INVALID_HEADER_VALUE", + true, + 0, + false, + before, + true, + ], + `${described} ${kind}`, + ); + } + } + }); + + test("[kNegotiator]() reads choose and forget once and refuses them unless they are functions, described, keeping nothing; what forget() throws is logged where a send settles", async () => { + for (const [given, message] of [ + [null, "`negotiator` is not an object: null"], + [{}, "`negotiator.choose` is not a function: undefined"], + [{ choose: noop, forget: 5 }, "`negotiator.forget` is not a function: 5"], + ] as const) { + const response = detached(); + const failed = await outcome(() => { + response[kNegotiator](given as never); + }); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + [message, true], + ); + // Nothing is kept: the body as it is + deepStrictEqual(response[kRepresentation](1), { + coding: null, + status: null, + }); + } + // Read once, called on the negotiator + const reads: string[] = []; + const receivers: unknown[] = []; + const negotiator = { + get choose(): (length: number | null) => unknown { + reads.push("choose"); + return function choose(this: unknown, length: number | null): unknown { + receivers.push(this); + return { coding: null, status: length }; + }; + }, + get forget(): () => void { + reads.push("forget"); + return noop; + }, + }; + const response = detached(); + response[kNegotiator](negotiator as never); + deepStrictEqual( + [response[kRepresentation](406), response[kRepresentation](406)], + [ + { coding: null, status: 406 }, + { coding: null, status: 406 }, + ], + ); + deepStrictEqual(reads, ["choose", "forget"]); + deepStrictEqual(receivers, [negotiator, negotiator]); + // A forget() that throws as a send fails before its first chunk: the + // send rejects with the error of its source + const failing = detached(); + failing[kNegotiator]({ + choose: () => ({ coding: null, status: null }), + forget(): never { + throw new OwnError("forget"); + }, + }); + const gone = new OwnError("gone"); + const source = new Readable({ + read(): void { + // Never + }, + }); + source.on("error", noop).destroy(gone); + strictEqual((await outcome(() => failing.send(source)))?.thrown, gone); + }); + + test("its constructor formats what the constructor of Node.js throws", async () => { + for (const given of [null, revoked()]) { + const failed = await outcome( + () => new BindenResponse(given as IncomingMessage), + ); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + } + ok( + new BindenResponse(new IncomingMessage(new Socket())) instanceof + ServerResponse, + ); + }); + + test("[kListen]() refuses a listener that is not a function before it adds it, and [kRevoke]() reads its arguments at once", async () => { + const response = detached(); + const failed = await outcome(() => response[kListen]("close", 1 as never)); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual(failed.thrown.message, "The listener is not a function: 1"); + ok(failed.formatted); + // Nothing is added: the event is emitted as without it + deepStrictEqual(response.emit("close"), false); + // Once, without the default + let runs = 0; + response[kListen]( + "custom", + () => { + runs += 1; + }, + { once: false }, + ); + response.emit("custom"); + response.emit("custom"); + deepStrictEqual(runs, 2); + }); +}); diff --git a/packages/binden/test/formatted-reasons.test.ts b/packages/binden/test/formatted-reasons.test.ts new file mode 100644 index 00000000..3075372a --- /dev/null +++ b/packages/binden/test/formatted-reasons.test.ts @@ -0,0 +1,205 @@ +import { Binden, type Context } from "../index.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { capture, listen, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; + +/** + * Runs `run` while `Error.prepareStackTrace` records each error whose stack + * V8 formats (once it is first read): an error of binden that is not + * formatted when the application or a source has it keeps the objects of its + * frames (e.g. the response) + */ +async function recording( + run: (formatted: WeakSet) => Promise | T, +): Promise { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): unknown => { + formatted.add(error); + return typeof previous === "function" + ? Reflect.apply(previous, Error, [error, frames]) + : [`${error.name}: ${error.message}`, ...frames.map(String)].join( + "\n at ", + ); + }, + ); + try { + return await run(formatted); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A stream-like source of its own, which tells whether each error that it is destroyed with is formatted then */ +class Source extends EventEmitter { + public readonly destroys: boolean[] = []; + + readonly #formatted: WeakSet; + + public constructor(formatted: WeakSet) { + super(); + this.#formatted = formatted; + } + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: Error): this { + if (typeof error !== "undefined") { + this.destroys.push(this.#formatted.has(error)); + } + return this; + } +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +suite( + "The errors of binden that a stream-like source is destroyed with, or that its send rejects with, are formatted before they are handed over", + () => { + test("the reason of a cancellation, before the source has it", async () => { + const destroys = await recording((formatted) => { + const source = new Source(formatted); + readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + () => null, + ).destroy(); + return source.destroys; + }); + deepStrictEqual(destroys, [true]); + }); + + test("the `TypeError` of a chunk that is not bytes, before the source has it (by the destroy of Node.js)", async () => { + const destroys = await recording((formatted) => { + const source = new Source(formatted); + readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + () => null, + ).on("error", () => null); + source.emit("data", 42); + return source.destroys; + }); + deepStrictEqual(destroys, [true]); + }); + + test("the premature close of a source that closes before its end, which the send rejects with (by the destroy of Node.js)", async () => { + const [code, isFormatted] = await recording((formatted) => { + const source = new Source(formatted); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + () => null, + ).on("error", () => null); + source.emit("close"); + const { errored } = readable; + return [ + (errored as { code?: unknown } | null)?.code, + errored !== null && formatted.has(errored), + ]; + }); + deepStrictEqual( + [code, isFormatted], + ["ERR_STREAM_PREMATURE_CLOSE", true], + ); + }); + }, +); + +suite( + "The errors of the framework that it cancels the sends of a request with are formatted before they are handed over", + () => { + test("the `503` of the deadline, which the error handler is given", async () => { + const seen = await recording(async (formatted) => { + const outcomes: unknown[] = []; + const app = new Binden({ + logger: capture().logger, + timeout: 20, + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + outcomes.push( + (error as { status?: unknown }).status, + formatted.has(error as object), + ); + await context.response.status(503).send("deadline"); + }, + }).use( + (context: Context) => + new Promise((resolve) => { + context.signal.addEventListener("abort", () => { + resolve(); + }); + }), + ); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + return [status, ...outcomes]; + } finally { + await listening.close(); + } + }); + deepStrictEqual(seen, [503, 503, true]); + }); + + test("the `500` of an error handler that has not settled in time, which its send that has not committed the headers rejects with", async () => { + const seen = await recording(async (formatted) => { + const outcomes: unknown[] = []; + const app = new Binden({ + logger: capture().logger, + error_handler_timeout: 20, + error_handler: async (context: Context): Promise => { + // A source that never emits anything + const source = Object.assign(new EventEmitter(), { + pause(): void { + // Never full + }, + resume(): void { + // Emits nothing + }, + }); + await context.send(source).catch((error: unknown) => { + outcomes.push( + (error as { status?: unknown }).status, + formatted.has(error as object), + ); + }); + }, + }).use(() => { + throw new Error("Failed"); + }); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + return [status, ...outcomes]; + } finally { + await listening.close(); + } + }); + deepStrictEqual(seen, [500, 500, true]); + }); + }, +); diff --git a/packages/binden/test/guard-decisions.test.ts b/packages/binden/test/guard-decisions.test.ts new file mode 100644 index 00000000..3c87262e --- /dev/null +++ b/packages/binden/test/guard-decisions.test.ts @@ -0,0 +1,138 @@ +import { Binden, type Context } from "../index.js"; +import { capture, ioTurn, listen, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** How a send has settled: resolved, or what it has rejected with */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => error, + ); +} + +/** + * Sends a source (made for the context) once: the status and the body that + * the client gets, how the send has settled, and the messages of the records + * of what has been logged, sorted (each with the message of its failure, or + * else its type, e.g. `string`) + */ +async function sent( + source: (context: Context) => Readable, +): Promise<[number, string, unknown, string[]]> { + const captured = capture(); + const outcomes: unknown[] = []; + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + outcomes.push(await outcomeOf(context.send(source(context)))); + if (!context.response.writableEnded) { + throw outcomes[0]; + } + }, + ), + ); + try { + const { status, body } = await send(listening.port); + // Any later record (e.g. of the guard, once the source emits its error) + await ioTurn(); + const records = captured.records + .filter(({ failure }) => typeof failure !== "undefined") + .map(({ msg, failure }) => { + const { message, type } = failure as { + message?: unknown; + type?: unknown; + }; + return `${String(msg)}: ${String(message ?? type)}`; + }) + .sort(); + return [status, body, outcomes[0], records]; + } finally { + await listening.close(); + } +} + +suite("A guard that a Node.js `Readable` refuses", () => { + test("is not logged with the object that has decided the send, thrown again by code of the source (for the listener of `close` of the guard); a primitive is", async () => { + for (const thrown of [new Error("No listener of close"), "no close"]) { + const [status, , outcome, records] = await sent(() => { + // Its own `on()` throws the same value for every listener of `close`: + // the one of the send (which rejects with it), then the one of its + // guard + const source = new Readable({ + read(): void { + // Never read + }, + }); + source.on("newListener", (event: string) => { + if (event === "close") { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- also a primitive, as code of a source may throw + throw thrown; + } + }); + return source; + }); + deepStrictEqual( + [status, outcome, records], + [ + 500, + thrown, + // A primitive is not recorded as decided: logged by the refusal, + // and once the source emits it (destroyed with it) + typeof thrown === "string" + ? [ + "A released stream has failed: string", + "The release of a stream has failed: string", + "The request has failed: string", + ] + : ["The request has failed: No listener of close"], + ], + ); + } + }); + + test("is not logged with the error that a destroyed source rejects its send with, thrown again (for the listener of `error` of its guard), nor once the response has ended, when that error is a late one, logged once", async () => { + for (const ended of [false, true]) { + const failure = new Error(`Destroyed with it${ended ? " (ended)" : ""}`); + const [status, body, outcome, records] = await sent( + (context: Context) => { + const source = new Readable({ + read(): void { + // Never read + }, + }); + source.destroy(failure); + // Its error is in place: no `error` listener before the guard's + source.on("error", () => null); + source.on("newListener", (event: string) => { + if (event === "error") { + throw failure; + } + }); + if (ended) { + // Its `errored`, read once by the send, ends the response first + Object.defineProperty(source, "errored", { + get(): Error { + context.response.end("ended"); + return failure; + }, + }); + } + return source; + }, + ); + deepStrictEqual( + [status, body, outcome, records], + ended + ? [ + 200, + "ended", + "resolved", + [`A released stream has failed: ${failure.message}`], + ] + : [500, "", failure, [`The request has failed: ${failure.message}`]], + ); + } + }); +}); diff --git a/packages/binden/test/guard-installation.test.ts b/packages/binden/test/guard-installation.test.ts new file mode 100644 index 00000000..2703bc4e --- /dev/null +++ b/packages/binden/test/guard-installation.test.ts @@ -0,0 +1,100 @@ +import { Binden, type Context } from "../index.js"; +import { capture, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** + * A `Readable` of `ok` (or of nothing, never ended: `silent`) whose listener + * of `newListener` emits an `error` while the second listener of its `error` + * is added: the one of its guard, once its send has settled + */ +function emitting(failure: Error, silent: boolean): Readable { + let listeners = 0; + const source = new Readable({ + read(): void { + if (!silent) { + this.push("ok"); + this.push(null); + } + }, + }); + source.on("newListener", (event: string) => { + if (event === "error") { + listeners += 1; + if (listeners === 2) { + source.emit("error", failure); + } + } + }); + return source; +} + +suite("An error that a source emits while its guard is installed", () => { + for (const ended of [false, true]) { + test(`is logged once as a late error, never taken by the send that has settled${ended ? " (ended by another writer)" : " (sent whole)"}: the reply is complete, and the next one is served`, async () => { + const failure = new Error("Emitted while guarded"); + const captured = capture(); + const outcomes: string[] = []; + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + if (context.url.pathname === "/next") { + await context.send("next"); + return; + } + const sent = context.send(emitting(failure, ended)); + if (ended) { + // By a source that sends nothing: the send settles as the + // response has ended, once it has finished + context.response.end("ok"); + } + outcomes.push( + await sent.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ), + ); + }, + ), + ); + try { + const { received } = await converse( + listening.port, + `${get("/")}${get("/next")}`, + { complete: (text) => text.endsWith("next") }, + ); + const late = (): unknown[] => + captured.records.filter( + ({ msg, failure: logged }) => + msg === "A released stream has failed" && + (logged as { message?: unknown }).message === failure.message, + ); + await within(pollUntil(() => late().length > 0, 5_000)); + // Any other report of it + await ioTurn(); + deepStrictEqual( + [ + received.match(/HTTP\/1\.1 \d+/gu), + // Whole (chunked when sent, by its length otherwise), then the next + received.includes( + ended + ? "\r\n\r\nokHTTP/1.1 200" + : "\r\n\r\n2\r\nok\r\n0\r\n\r\nHTTP/1.1 200", + ), + received.endsWith("next"), + outcomes, + late().length, + captured.records + .filter(({ level }) => (level as number) >= 40) + .map(({ msg }) => msg), + ], + [["HTTP/1.1 200", "HTTP/1.1 200"], true, true, ["resolved"], 1, []], + ); + } finally { + await listening.close(); + } + }); + } +}); diff --git a/packages/binden/test/guard-refusals.test.ts b/packages/binden/test/guard-refusals.test.ts new file mode 100644 index 00000000..5097d5d2 --- /dev/null +++ b/packages/binden/test/guard-refusals.test.ts @@ -0,0 +1,526 @@ +import { Binden, type Context } from "../index.js"; +import { + type IGuardMethods, + type IStreamLikeMethods, + decided, + emitterMethods, + forwarder, + guard, + hasClosed, + readStreamLike, + releaseStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { capture, ioTurn, listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, without a handler + * of the process, with these flags of Node.js: a crash (e.g. an unhandled + * `error` event) is its exit code and output, not a failure of the test + * runner + */ +function fixture(name: string, ...flags: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + ...flags, + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 40_000 }, + ); + return { status, stdout, stderr }; +} + +/** The sources of the stream-like cases: not streams, and of `readable-stream` 2, 3 and 4 */ +const libraries = ["emitter", "v2", "v3", "v4"] as const; + +/** The line of each source of a case */ +function each( + name: string, + line: (library: (typeof libraries)[number]) => string, + sources: readonly (typeof libraries)[number][] = libraries, +): string[] { + return sources.map((library) => `${library} ${name} ${line(library)}`); +} + +/** + * Whether the guard of a source of a library leaves once it has closed: only + * a `Readable` of `readable-stream` 4, whose state tells it (`closeEmitted`). + * The others keep theirs (as long as they live, holding nothing but the + * report of a late error): they may emit an error after their close. + */ +function closes(library: string): boolean { + return library === "v4"; +} + +/** What the fixture of the stream-like sources has printed (run once) */ +let printed: Record | null = null; + +/** + * What the fixture of the stream-like sources has printed for these cases, + * with its exit code and its errors (`counts`: its last line, the counts of + * every case) + */ +function streamLikes( + cases: readonly string[], + counts = false, +): Record { + printed ??= fixture("stream-like-refusals.ts"); + const lines = String(printed.stdout).split("\n"); + return { + status: printed.status, + lines: [ + ...lines.filter((line) => cases.includes(line.split(" ")[1] ?? "")), + ...(counts ? lines.slice(-2) : []), + ], + stderr: printed.stderr, + }; +} + +suite("A source that refuses the listener of `error` of its guard", () => { + test("a Node.js `Readable`: it keeps the one of its send, which logs its late error, and is destroyed without the error of the send; one that refuses every one is left untouched", () => { + // `/every` refuses every listener of `error` (also of the send, which + // rejects with it), `/head` the one of its guard (released to `HEAD`): + // neither is destroyed. The others refuse the one of their guard (the + // second): a body longer than its `Content-Length` (not destroyed with + // that error), sent whole (destroyed by `autoDestroy`, or by the + // application later), cancelled at the deadline, or failed by an error + // that its own `on()` emits once the send listens (`/emitted`, heard by + // the listener of the send, which stays). Each destroy fails 20 ms + // later, logged once. + deepStrictEqual(fixture("guard-refusals.ts"), { + status: 0, + stdout: + "/every 500 rejected:No listener of error #1 destroyed=false errored=null paused=false errors=0\n" + + "/guard 500 rejected:ERR_HTTP_CONTENT_LENGTH_MISMATCH destroyed=true errored=Late failure (/guard) paused=false errors=1\n" + + "/ended 200 resolved destroyed=true errored=Late failure (/ended) paused=false errors=1\n" + + "/later 200 resolved destroyed=true errored=Late failure (/later) paused=false errors=1\n" + + "/deadline 503 rejected:The request has been aborted (deadline) destroyed=true errored=Late failure (/deadline) paused=false errors=1\n" + + "/head 200 resolved destroyed=false errored=null paused=false errors=0\n" + + "/emitted 500 rejected:Emitted at once destroyed=true errored=Late failure (/emitted) paused=false errors=1\n" + + `${["/deadline", "/emitted", "/ended", "/guard", "/later"].map((path) => `Late failure (${path})`).join(", ")}\n` + + // Once per source; the sources left: `/every` and `/head` + "refused=7 left=2\n", + stderr: "", + }); + }); + + test("its listener of `error` kept by the send does not keep the response, nor does a guard", () => { + // 100 Node.js `Readable`s and 100 stream-like sources, each with the + // listener of its send, and 100 Node.js `Readable`s with their guard, + // each of which logs its late error once (200 guards refused) + deepStrictEqual(fixture("fallback-retention.ts", "--expose-gc"), { + status: 0, + stdout: "collected 100 100 100 300 200\n", + stderr: "", + }); + }); +}); + +suite( + "A stream-like source that refuses the listener of `error` of its guard", + () => { + test("keeps the one of its send, which logs a later error once, and is destroyed without the error of the send; one that refuses every one is left", () => { + const lines = [ + // Sent whole, or failed + ...each( + "guard-end", + (library) => + `200 resolved destroyed=${library === "v4" ? "undefined" : ""} listeners=0/0/1/0 late=1`, + ), + // Released once it has failed: destroyed without its error + ...each( + "guard-error", + () => + "500 rejected:Its I/O has failed destroyed=undefined listeners=0/0/1/0 late=1", + ["emitter"], + ), + // Destroyed without the reason of the deadline: its destroy fails + // later, logged once + ...each( + "guard-deadline", + () => + "503 rejected:The request has been aborted (deadline) destroyed=undefined listeners=0/0/1/0 late=1", + ), + // Released to `HEAD`, refusing every listener of `error`: never + // destroyed (its destroy would fail later), only left + ...each( + "every-head", + () => "200 resolved destroyed= listeners=0/0/0/0 late=0", + ["emitter"], + ), + // Failed by an error that its own `on()` emits once the send + // listens: the listener of the send, which has heard it, stays + ...each( + "guard-emitted", + () => + "500 rejected:Its I/O has failed at once destroyed=undefined listeners=0/0/1/0 late=1", + ["emitter"], + ), + ]; + deepStrictEqual( + streamLikes([ + "guard-end", + "guard-error", + "guard-deadline", + "every-head", + "guard-emitted", + ]), + { status: 0, lines, stderr: "" }, + ); + }); + }, +); + +suite("Code of a stream-like source that throws while send() reads it", () => { + test("its `on()` (also a listener of its `newListener`), its `removeListener()`, its `pause()`, its `resume()` (a falsy value), or its state (failed without being destroyed): nothing crashes, nothing of the send is left, and the send settles as for a Node.js `Readable`", () => { + const lines = [ + // Rejected with what has been thrown, and destroyed with it (not + // logged): only its guard stays (its listener of `error`, without its + // listener of `close`, which it refuses), as long as it lives + ...each( + "newListener-close", + () => + "500 rejected:newListener(close) refused destroyed=newListener(close) refused listeners=0/0/1/0 late=0", + ), + ...["end", "data"].flatMap((event) => + each( + `newListener-${event}`, + (library) => + `500 rejected:newListener(${event}) refused destroyed=newListener(${event}) refused listeners=0/0/${closes(library) ? "0/0" : "1/1"} late=0`, + ), + ), + // Every listener of `error` refused: never destroyed (its destroy + // would fail later), only left + ...each( + "every", + () => + "500 rejected:No listener of error #1 destroyed= listeners=0/0/0/0 late=0", + ), + // A removal that throws is logged: the listener left stays harmless + // (that of `error` logs a later error once). Of `readable-stream` 4: + // closed by `autoDestroy`, its guard has left, its listener of `close` + // removed on its own once the removal of the other one has thrown + ...each( + "removeListener-error", + (library) => + `200 resolved destroyed=${library === "v4" ? "undefined" : ""} listeners=0/0/2/${library === "v4" ? 0 : 1} late=1`, + ), + // Of `readable-stream` 4: closed by `autoDestroy`, its guard has left + ...each( + "removeListener-end", + () => "200 resolved destroyed= listeners=0/1/1/1 late=0", + ["emitter", "v2", "v3"], + ), + "v4 removeListener-end 200 resolved destroyed=undefined listeners=0/1/0/0 late=0", + ...each( + "removeListener-all", + (library) => + `200 resolved destroyed=${library === "v4" ? "undefined" : ""} listeners=1/1/2/2 late=1`, + ), + // Its `pause()` throws once the response is full: the response, + // committed, is closed; destroyed with that error, or paused (which + // throws again, logged) without `destroy()`. The emitters keep their + // guards: their state cannot tell that they have closed + "emitter pause 0 rejected:pause() refused destroyed=pause() refused listeners=0/0/1/1 late=0", + "emitter pause-undestroyable 0 rejected:pause() refused #1 destroyed= listeners=0/0/1/1 late=0", + // A string thrown by its own `on()`: logged once (by its guard, as its + // destroy emits it), not by the readable that the send rejects with + "emitter on-string 500 rejected:Thrown destroyed=Thrown listeners=0/0/1/1 late=0", + // A falsy `errored` is no error: sent + "emitter errored-false 200 resolved destroyed= listeners=0/0/1/1 late=0", + // Failed without being destroyed: rejected at once with its error, and + // released with it (not logged) + "emitter errored 500 rejected:Errored before destroyed=Errored before listeners=0/0/1/1 late=0", + 'v4 errored-v4 500 rejected:ERR_INVALID_ARG_TYPE destroyed=The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received type number (42) listeners=0/0/1/0 late=0', + "emitter errored-state 500 rejected:Errored before destroyed=Errored before listeners=0/0/1/1 late=0", + // A falsy value, which Node.js drops as an error: a premature close + ...["null", "undefined"].map( + (value) => + `emitter resume-${value} 500 rejected:ERR_STREAM_PREMATURE_CLOSE destroyed=Premature close listeners=0/0/1/1 late=0`, + ), + // Of every case: the removals that have thrown (each of the guard of + // `readable-stream` 4 on its own), the guards refused (once per + // source), the sources left, and the strings logged + "removals=27 refusals=15 left=5 strings=1", + ]; + deepStrictEqual( + streamLikes( + [ + "newListener-close", + "newListener-end", + "newListener-data", + "every", + "removeListener-error", + "removeListener-end", + "removeListener-all", + "pause", + "pause-undestroyable", + "on-string", + "errored-false", + "errored", + "errored-v4", + "errored-state", + "resume-null", + "resume-undefined", + ], + true, + ), + { status: 0, lines: [...lines, ""], stderr: "" }, + ); + }); +}); + +/** A stream-like source that is not a stream, whose calls are recorded */ +class Recorded extends EventEmitter { + public readonly calls: string[] = []; + + public pause(): this { + this.calls.push("pause"); + return this; + } + + public resume(): this { + this.calls.push("resume"); + return this; + } + + public destroy(): this { + this.calls.push("destroy"); + return this; + } +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +suite("guard() of a source", () => { + test("is installed only once its listener of `error` is in place: what its code throws is given to `refused` (also for the removal of a listener that it may have added), and a source that refuses it is not asked again", () => { + const source = new EventEmitter(); + const calls: string[] = []; + const refused: unknown[] = []; + const methods: IGuardMethods = { + on: (event: unknown) => { + calls.push(`on ${String(event)}`); + throw new Error("No listener"); + }, + remove: (event: unknown) => { + calls.push(`remove ${String(event)}`); + throw new Error("No removal"); + }, + }; + const report = (error: unknown): void => { + refused.push(`report ${(error as Error).message}`); + }; + const results = [ + guard(source, methods, report, false, (error: unknown) => { + refused.push((error as Error).message); + }), + // Not asked again, nor by the methods of Node.js + guard(source, emitterMethods, report), + ]; + deepStrictEqual( + [results, calls, refused, source.listenerCount("error")], + [ + [false, false], + ["on error", "remove error"], + ["No listener", "No removal"], + 0, + ], + ); + }); + + test("of a source guarded meanwhile (e.g. by a listener of its `newListener` that releases it): one guard", async () => { + const source = new EventEmitter(); + const reports: unknown[] = []; + const report = (error: unknown): void => { + reports.push((error as Error).message); + }; + let nested: boolean | null = null; + source.once("newListener", () => { + nested = guard(source, emitterMethods, report); + }); + const guarded = guard(source, emitterMethods, report); + source.emit("error", new Error("Failed")); + // Reported on a later turn + await ioTurn(); + deepStrictEqual( + [ + guarded, + nested, + reports, + source.listenerCount("error"), + source.listenerCount("close"), + ], + [true, true, ["Failed"], 1, 1], + ); + }); + + test("forwarder(): forwards to its target until it is detached, then reports a late error as a guard does: not a decided one, nor one that the guard of the source reports", async () => { + const source = new EventEmitter(); + const forwarded: unknown[] = []; + const reports: unknown[] = []; + const forward = forwarder((error: unknown) => { + forwarded.push(error); + }); + forward.listener("Heard"); + forward.detach(source, (error: unknown) => { + reports.push(error); + }); + const failure = new Error("Decided"); + decided(failure); + forward.listener(failure); + forward.listener("Late"); + guard(source, emitterMethods, () => null); + forward.listener("Guarded"); + // Reported on a later turn + await ioTurn(); + deepStrictEqual([forwarded, reports], [["Heard"], ["Late"]]); + }); + + test("hasClosed(): a state that cannot be read (a getter that throws) does not tell", () => { + const source = Object.defineProperty({}, "_readableState", { + get(): never { + throw new Error("No state"); + }, + }); + deepStrictEqual(hasClosed(source), false); + }); +}); + +suite("A stream-like source used without the framework", () => { + test("whose own `on()` emits an `error` at once: it settles, and no other listener is added", async () => { + const source = new Recorded(); + const failure = new Error("Emitted at once"); + const added: string[] = []; + Object.assign(source, { + on(event: string, listener: () => void): Recorded { + added.push(event); + EventEmitter.prototype.on.call(source, event, listener); + if (event === "error" && added.length === 1) { + source.emit("error", failure); + } + return source; + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + () => null, + ); + const [error] = (await within( + new Promise((resolve) => { + readable.once("error", (...args: unknown[]) => { + resolve(args); + }); + }), + )) as [unknown]; + // Its guard, after the listener of the send; released once it has + // failed (destroyed without its error) + deepStrictEqual( + [error, added, source.calls], + [failure, ["error", "error", "close"], ["destroy"]], + ); + }); + + test("released that refuses its guard: left untouched (neither paused nor destroyed), the refusal reported", () => { + const source = new Recorded(); + Object.assign(source, { + on(event: string, listener: () => void): Recorded { + if (event === "error") { + throw new Error("No listener"); + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + }); + const reports: unknown[] = []; + releaseStreamLike(source, methodsOf(source), (error: unknown) => { + reports.push((error as Error).message); + }); + deepStrictEqual([source.calls, reports], [[], ["No listener"]]); + }); +}); + +suite( + "A Node.js `Readable` whose own `removeListener()` throws for `close`", + () => { + test("its listener of `close` that the send has left changes nothing once the send has settled", async () => { + const source = new (class extends Readable { + public override removeListener( + ...args: Parameters + ): this { + if (args[0] === "close") { + throw new Error("No removal of close"); + } + return super.removeListener(...args); + } + })({ + read(): void { + // Longer than its `Content-Length`: the send fails, then destroys + // it, which closes it before its end + this.push("too long"); + }, + }); + // A listener that `once()` would remove cannot be + const closed = new Promise((resolve) => { + source.on("close", resolve); + }); + const captured = capture(); + const sent = Promise.withResolvers(); + const listening = await listen( + new Binden({ logger: captured.logger }).use( + async (context: Context) => { + context.response.setHeader("Content-Length", 2); + sent.resolve( + await context.send(source).then( + () => "resolved", + (error: unknown) => (error as { code?: unknown }).code, + ), + ); + throw new Error("Replied"); + }, + ), + ); + try { + const received = await send(listening.port); + const outcome = await within(sent.promise); + await within(closed); + // Its guard leaves on the turn after its close + await ioTurn(); + // The listener of the send that is left, and the one of the test; + // the error of the send only (not again by its close) + deepStrictEqual( + [ + received.status, + outcome, + source.listenerCount("close"), + captured.records + .filter(({ level }) => (level as number) >= 50) + .map(({ msg }) => msg), + ], + [ + 500, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + 2, + ["The request has failed"], + ], + ); + } finally { + await listening.close(); + } + }); + }, +); diff --git a/packages/binden/test/guard-removals.test.ts b/packages/binden/test/guard-removals.test.ts new file mode 100644 index 00000000..4736c995 --- /dev/null +++ b/packages/binden/test/guard-removals.test.ts @@ -0,0 +1,165 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { capture, ioTurn, listen, pollUntil, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { createRequire } from "node:module"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const require = createRequire(import.meta.url); + +/** The message of a removal of a listener of a stream that throws */ +const removal = "The removal of a listener of a stream has failed"; + +/** + * Sends `source` to `GET /` (and runs `sent()` once the send has resolved) + * while the client reads the response, then waits until `done()` holds: the + * status, the body, and the records of the removals that have thrown and of + * the late errors, as `message [failure]` + */ +async function run( + source: Readable, + done: () => boolean, + sent: () => void = (): void => { + // Nothing to do + }, +): Promise<[number, string, string[]]> { + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + await context.send(source); + sent(); + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + await pollUntil(done); + await ioTurn(); + const records = captured.records + .filter(({ msg }) => msg === removal || msg === releasedFailure) + .map( + ({ msg, failure }) => + `${String(msg)} [${String((failure as { message?: unknown }).message)}]`, + ); + return [status, body, records]; + } finally { + await listening.close(); + } +} + +suite( + "The guard of a source that leaves on the turn after its close, whose removals throw", + () => { + test("(`readable-stream` 4, whose own `removeListener()` throws for every listener once it has removed it): each of its listeners is removed on its own, and each throw is logged as a removal, never as a late error (R3.21-6)", async () => { + const { Readable: Readable4 } = require("readable-stream-v4") as { + Readable: typeof Readable; + }; + let pushed = 0; + const source = new Readable4({ + read(): void { + pushed += 1; + this.push(pushed <= 2 ? "x" : null); + }, + }); + const remove = source.removeListener.bind(source); + Object.assign(source, { + removeListener(event: string, listener: () => void): Readable { + remove(event, listener); + throw new Error(`No removal of ${event}`); + }, + }); + const [status, body, records] = await run( + source, + () => + source.listenerCount("error") === 0 && + source.listenerCount("close") === 0, + ); + deepStrictEqual( + [status, body, records], + [ + 200, + "xx", + [ + // Of the send, once it has settled (the one of `error` on the + // next turn) + `${removal} [No removal of data]`, + `${removal} [No removal of end]`, + `${removal} [No removal of close]`, + `${removal} [No removal of error]`, + // Of its guard, once it has closed + `${removal} [No removal of error]`, + `${removal} [No removal of close]`, + ], + ], + ); + }); + + test("(a Node.js `Readable`, whose listener of `removeListener` throws once the send has settled): each of its listeners is removed, and each throw is logged as a removal, never as a late error (R3.21-6)", async () => { + let armed = false; + let pushed = 0; + const source = new Readable({ + read(): void { + pushed += 1; + this.push(pushed <= 2 ? "x" : null); + }, + destroy(error, callback): void { + // It closes once the send has resolved (and has removed its + // listeners): only the removals of its guard throw + const closing = (): void => { + if (armed) { + callback(error); + } else { + setTimeout(closing, 5); + } + }; + closing(); + }, + }); + source.on("removeListener", (event: string) => { + if (armed) { + throw new Error(`No removal of ${event}`); + } + }); + const [status, body, records] = await run( + source, + () => + source.closed && + source.listenerCount("error") === 0 && + source.listenerCount("close") === 0, + () => { + armed = true; + }, + ); + deepStrictEqual( + [status, body, records], + [ + 200, + "xx", + [ + `${removal} [No removal of error]`, + `${removal} [No removal of close]`, + ], + ], + ); + }); + + test("whose logger throws for them too: does not crash the process, and both of its listeners are removed", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "guard-removal-logs.ts"), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual( + { status, stdout, stderr }, + { status: 0, stdout: "left 0 0\n", stderr: "" }, + ); + }); + }, +); diff --git a/packages/binden/test/guarded-records.test.ts b/packages/binden/test/guarded-records.test.ts new file mode 100644 index 00000000..1b1f8b77 --- /dev/null +++ b/packages/binden/test/guarded-records.test.ts @@ -0,0 +1,224 @@ +import { + Binden, + BindenRequest, + BindenResponse, + Context, + type IOwnership, + Middleware, + releasedFailure, +} from "../index.js"; +import { capture, ioTurn, listen, send } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { kOwnership, owners } from "../src/seal.js"; +import { suite, test } from "node:test"; +import type { ILogger } from "@binden/logger"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; + +/** The methods (and `child()`) of a logger */ +const methods = [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + "child", +] as const; + +/** Replaces the methods (and `child()`) of a logger with ones that count their calls and throw */ +function replace(logger: ILogger, calls: string[]): void { + for (const method of methods) { + Object.assign(logger, { + [method]: (): never => { + calls.push(method); + throw new Error("A replaced method"); + }, + }); + } +} + +suite( + "The records of binden cannot be broken by the application (R29-C1)", + () => { + test("a late error is logged by the private path of a logger of binden whose methods and `child()` have been replaced since", async () => { + const captured = capture(); + const message = new BindenRequest(new Socket()); + // A logger of binden over the captured one + const logger = new Context({ + request: message, + response: new BindenResponse(message), + logger: captured.logger, + }).log; + const failure = new Error("The source has failed before its send"); + const app = new Binden({ logger, log_error_details: true }).use( + async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + const source = new Readable({ + read(): void { + // Never read + }, + }); + source.destroy(failure); + // Ends the response once `send()` reads it: its error is emitted, and + // reported late, once the send has settled + Object.defineProperty(source, "destroyed", { + get(): boolean { + context.response.end("complete"); + return true; + }, + }); + await context.send(source); + }, + ); + const calls: string[] = []; + replace(logger, calls); + const listening = await listen(app); + try { + const { received } = await converse( + listening.port, + `${get("/")}${get("/next")}`, + { complete: (text) => text.endsWith("\r\n\r\nnext") }, + ); + await ioTurn(); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:complete|next)/gu), + captured.records + .filter(({ msg }) => msg === releasedFailure) + .map( + ({ failure: logged }) => + (logged as { message?: unknown }).message, + ), + ], + [["\r\n\r\ncomplete", "\r\n\r\nnext"], [failure.message]], + ); + deepStrictEqual(calls, []); + } finally { + await listening.close(); + } + }); + + test("the handlers of the ownership, the report of a cleanup and the failure of a pipe of the stack once it has failed log by the private path: the methods replaced on `context.log` never run", async () => { + const captured = capture(); + const prototype = BindenResponse.prototype as unknown as Record< + typeof kOwnership, + (ownership: IOwnership) => void + >; + const install = prototype[kOwnership]; + let ownership: IOwnership | null = null; + // The handlers that the application installs on its responses + prototype[kOwnership] = function installed( + this: BindenResponse, + given: IOwnership, + ): void { + ownership = given; + Reflect.apply(install, this, [given]); + }; + const calls: string[] = []; + let stack: unknown = null; + const app = new Binden({ + logger: captured.logger, + error_handler: async (context: Context): Promise => { + // A pipe of the stack fails once the error handler runs + ok( + ownership?.fail(stack as object, new Error("A late pipe")) === true, + ); + await context.status(418).send("handled"); + }, + }).use((context: Context) => { + replace(context.log, calls); + context.onCleanup(() => { + throw new Error("A failing cleanup"); + }); + const handlers = ownership as unknown as IOwnership; + handlers.warn("A warning", "a reason"); + handlers.note("A note", "a reason"); + handlers.debug("A failure", new Error("A failure")); + stack = owners.getStore(); + throw new Error("A failure of the stack"); + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + await captured.waitFor("A cleanup of the request has failed"); + const logged = captured.records.map(({ msg }) => msg); + deepStrictEqual( + [ + status, + body, + [ + "A warning", + "A note", + "A failure", + "A pipe of the stack has failed once it had failed", + "A cleanup of the request has failed", + ].filter((message) => !logged.includes(message)), + calls, + ], + [418, "handled", [], []], + ); + } finally { + prototype[kOwnership] = install; + await listening.close(); + } + }); + + test("a middleware that ignores its errors is passed once getting the logger of its context throws", async () => { + class Failing extends Middleware { + // eslint-disable-next-line class-methods-use-this -- a middleware that fails + public run(): never { + throw new Error("An ignored failure"); + } + } + const app = new Binden().use( + (context: Context) => { + Object.defineProperty(context, "log", { + get: (): never => { + throw new Error("No logger"); + }, + }); + }, + new Failing({ ignore_errors: true }), + (context: Context) => context.send("passed"), + ); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + deepStrictEqual([status, body], [200, "passed"]); + } finally { + await listening.close(); + } + }); + + test("a middleware that returns an object derived from a context, or a proxy of one, keeps the context, told by its brand", async () => { + const app = new Binden().use( + (context: Context) => { + if (context.url.pathname === "/derived") { + return Object.create(context) as Context; + } + return new Proxy(context, {}); + }, + (context: Context) => context.send("kept"), + ); + const listening = await listen(app); + try { + const { received } = await converse( + listening.port, + `${get("/derived")}${get("/proxy")}`, + { complete: (text) => text.split("\r\n\r\nkept").length === 3 }, + ); + deepStrictEqual(received.match(/HTTP\/1\.1 \d+/gu), [ + "HTTP/1.1 200", + "HTTP/1.1 200", + ]); + } finally { + await listening.close(); + } + }); + }, +); diff --git a/packages/binden/test/head-snapshots.test.ts b/packages/binden/test/head-snapshots.test.ts new file mode 100644 index 00000000..b5e496e5 --- /dev/null +++ b/packages/binden/test/head-snapshots.test.ts @@ -0,0 +1,593 @@ +import { + Binden, + BindenResponse, + type Context, + kValidateHead, +} from "../index.js"; +import { + IncomingMessage, + type Server, + ServerResponse, + createServer, +} from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { listen, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; +import { join } from "node:path"; +import { kSeal } from "../src/seal.js"; +import { readFileSync } from "node:fs"; + +/** A value that counts its conversions, which converts to `converted` */ +interface ICounting { + readonly value: object; + conversions(): number; +} + +/** A value whose `toString()`, `valueOf()` and `@@toPrimitive` count their calls */ +function counting(converted: number | string): ICounting { + let conversions = 0; + const convert = (): number | string => { + conversions += 1; + return converted; + }; + return { + value: { + toString: convert, + valueOf: convert, + [Symbol.toPrimitive]: convert, + }, + conversions: () => conversions, + }; +} + +/** A response of binden, or of plain Node.js, to an HTTP/1.1 request without a connection */ +function responseOf(binden: boolean): ServerResponse { + const request = new IncomingMessage(new Socket()); + request.httpVersionMajor = 1; + request.httpVersionMinor = 1; + return binden ? new BindenResponse(request) : new ServerResponse(request); +} + +/** What a call has done to a response: what it threw, or the head that it has committed (without its `Date`) */ +function outcome(response: ServerResponse, run: () => unknown): unknown { + try { + run(); + } catch (error: unknown) { + const { code, message } = error as { code?: unknown; message: string }; + return [(error as Error).constructor.name, code, message]; + } + return String(Reflect.get(response, "_header")).replaceAll( + /Date: [^\r]*\r\n/gu, + "", + ); +} + +/** The calls of `writeHead()` with a counting value, as an argument or a header value */ +const calls: Record< + string, + [(response: ServerResponse, value: object) => unknown, number | string] +> = { + "an invalid status": [ + (response, value): unknown => response.writeHead(value as never), + "a", + ], + "a valid status": [ + (response, value): unknown => response.writeHead(value as never), + 201, + ], + "a value of an object": [ + (response, value): unknown => + response.writeHead(200, { "x-a": value as never }), + "a", + ], + "a value of an object after a reason phrase": [ + (response, value): unknown => + response.writeHead(200, "OK", { "x-a": value as never }), + "a", + ], + "a value of flat pairs": [ + (response, value): unknown => + response.writeHead(200, ["x-a", value as never]), + "a", + ], + "a value of nested pairs": [ + (response, value): unknown => + response.writeHead(200, [["x-a", value]] as never), + "a", + ], + "an element of the values of a header": [ + (response, value): unknown => + response.writeHead(200, { "x-a": [value] as never }), + "a", + ], + "an invalid value": [ + (response, value): unknown => + response.writeHead(200, { "x-a": value as never }), + "a\n", + ], + "a name of flat pairs": [ + (response, value): unknown => + response.writeHead(200, [value as never, "b"]), + "x-a", + ], +}; + +suite( + "writeHead() reads what it checks once, and gives it to Node.js (R27-R3-4)", + () => { + test("converts the status and the values (and the names) of the headers as often as plain Node.js, with the same outcome, also once headers have been set", () => { + for (const [name, [call, converted]] of Object.entries(calls)) { + for (const set of [false, true]) { + const counts: number[] = []; + const outcomes: unknown[] = []; + for (const binden of [false, true]) { + const response = responseOf(binden); + if (set) { + response.setHeader("x-b", "c"); + } + const value = counting(converted); + outcomes.push(outcome(response, () => call(response, value.value))); + counts.push(value.conversions()); + } + const [node, ours] = counts as [number, number]; + deepStrictEqual(ours, node, `${name} (set: ${String(set)})`); + deepStrictEqual( + outcomes[1], + outcomes[0], + `${name} (set: ${String(set)})`, + ); + } + } + }); + + test("reads each value once: Node.js gets exactly the values that binden has checked (a getter that changes them, a proxy of the headers)", () => { + let reads = 0; + const changing = { + get "x-a"(): string { + reads += 1; + return reads === 1 ? "first" : "bad\n"; + }, + }; + const response = responseOf(true); + response.writeHead(200, changing); + ok( + String(Reflect.get(response, "_header")).includes("x-a: first\r\n"), + "the value checked", + ); + deepStrictEqual(reads, 1); + // A value that is invalid when it is checked: refused, read once + reads = 1; + const refusing = responseOf(true); + const failed = outcome(refusing, () => refusing.writeHead(200, changing)); + deepStrictEqual( + [failed, reads, refusing.headersSent], + [ + [ + "TypeError", + "ERR_INVALID_CHAR", + 'Invalid character in header content ["x-a"]', + ], + 2, + false, + ], + ); + // The indexes of an array (also of its pairs) read once each + const indexes = new Map(); + const counted = (target: T, path: string): T => + new Proxy(target, { + get(object: T, key: PropertyKey): unknown { + if (typeof key === "string" && /^\d+$/u.test(key)) { + const at = `${path}.${key}`; + indexes.set(at, (indexes.get(at) ?? 0) + 1); + } + return Reflect.get(object, key); + }, + }); + for (const given of [ + counted(["x-a", "b", "x-c", "d"], "flat"), + counted([counted(["x-a", "b"], "pair")], "nested"), + { "x-a": counted(["b", "c"], "values") }, + ]) { + responseOf(true).writeHead(200, given as never); + } + deepStrictEqual(Object.fromEntries(indexes), { + "flat.0": 1, + "flat.1": 1, + "flat.2": 1, + "flat.3": 1, + "nested.0": 1, + "pair.0": 1, + "pair.1": 1, + "values.0": 1, + "values.1": 1, + }); + }); + + test("converts the status once, and Node.js gets that number; refuses it as Node.js does, by the value given, before anything is read", () => { + let conversions = 0; + const changing = { + valueOf(): number { + conversions += 1; + return conversions === 1 ? 202 : 99; + }, + }; + const response = responseOf(true); + response.writeHead(changing as never); + deepStrictEqual( + [response.statusCode, response.headersSent, conversions], + [202, true, 1], + ); + for (const status of ["abc", 99, 1000, 1.5e9]) { + let read = false; + const headers = { + get "x-a"(): string { + read = true; + return "b"; + }, + }; + const [node, ours] = [false, true].map((binden) => { + const refusing = responseOf(binden); + return outcome(refusing, () => + refusing.writeHead(status as never, headers), + ); + }); + deepStrictEqual(ours, node, String(status)); + ok(Array.isArray(ours) && ours[1] === "ERR_HTTP_INVALID_STATUS_CODE"); + deepStrictEqual(read, false); + } + }); + + test("[kValidateHead]() throws ERR_HTTP_HEADERS_SENT once the head is committed, as writeHead() of Node.js, reading and converting nothing", () => { + const response = responseOf(true) as BindenResponse; + response.writeHead(200); + const status = counting(200); + let read = false; + const headers = { + get "x-a"(): string { + read = true; + return "b"; + }, + }; + deepStrictEqual( + outcome(response, () => + response[kValidateHead](status.value, "OK", headers), + ), + [ + "Error", + "ERR_HTTP_HEADERS_SENT", + "Cannot write headers after they are sent to the client", + ], + ); + deepStrictEqual([status.conversions(), read], [0, false]); + // As it returns them before: the status converted, the copies + const fresh = responseOf(true) as BindenResponse; + const [code, reason, given] = fresh[kValidateHead](status.value, { + "x-a": ["b"], + }); + deepStrictEqual([code, reason, given], [200, null, { "x-a": ["b"] }]); + }); + }, +); + +/** A file that `sendFile()` sends */ +const file = join(import.meta.dirname, "..", "package.json"); + +/** + * The calls that commit the head implicitly, for binden and for plain + * Node.js: a send of binden is an `end()` of the same body (a `write()` of + * its chunk for a `Readable`, the content of the file for `sendFile()`) + */ +const implicit: Record< + string, + [(response: BindenResponse) => unknown, (response: ServerResponse) => unknown] +> = { + "write() of a string": [ + (response): unknown => response.write("abc"), + (response): unknown => response.write("abc"), + ], + "write() of bytes": [ + (response): unknown => response.write(Buffer.from("abc")), + (response): unknown => response.write(Buffer.from("abc")), + ], + "end() of a chunk": [ + (response): unknown => response.end("abc"), + (response): unknown => response.end("abc"), + ], + "end()": [ + (response): unknown => response.end(), + (response): unknown => response.end(), + ], + "flushHeaders()": [ + (response): void => { + response.flushHeaders(); + }, + (response): void => { + response.flushHeaders(); + }, + ], + "send() of a string": [ + async (response): Promise => response.send("abc"), + (response): unknown => response.end("abc"), + ], + "send() of bytes": [ + async (response): Promise => response.send(Buffer.from("abc")), + (response): unknown => response.end(Buffer.from("abc")), + ], + "send() of a Readable": [ + async (response): Promise => response.send(Readable.from(["abc"])), + (response): unknown => response.write("abc") && response.end(), + ], + "send() without a body": [ + async (response): Promise => response.send(), + (response): unknown => response.end(), + ], + "json()": [ + async (response): Promise => response.json("abc"), + (response): unknown => response.end(`"abc"`), + ], + "sendFile()": [ + async (response): Promise => response.sendFile(file), + (response): unknown => response.end(readFileSync(file)), + ], +}; + +/** + * Replies a request by a call that commits the head with a `statusCode` that + * counts its conversions (see `counting()`, or one whose conversion throws): + * the conversions, what the call has thrown or rejected with (the reply is + * then a `500`), and the status and the body that the client gets + */ +async function committed( + binden: boolean, + name: string, + converted: number | string | null, +): Promise { + const [ours, node] = implicit[name] as [ + (response: BindenResponse) => unknown, + (response: ServerResponse) => unknown, + ]; + const failure = new Error("A conversion"); + const status = + converted === null + ? { + conversions: (): number => 1, + value: { + [Symbol.toPrimitive](): never { + throw failure; + }, + }, + } + : counting(converted); + const settled = Promise.withResolvers(); + /** Settles the reply once the call has: replies `500` once it has failed */ + const finish = (response: ServerResponse, error: unknown): void => { + if (error === null) { + settled.resolve(null); + } else { + const { code, message } = error as { code?: unknown; message: string }; + settled.resolve( + error === failure + ? "the error of the conversion" + : [(error as Error).constructor.name, code, message], + ); + response.statusCode = 500; + response.end("failed"); + } + if (!response.writableEnded) { + response.end(); + } + }; + const reply = async (response: ServerResponse): Promise => { + response.statusCode = status.value as never; + // What it throws rejects, as what it rejects with + await new Promise((resolve) => { + resolve(binden ? ours(response as BindenResponse) : node(response)); + }).then( + () => { + finish(response, null); + }, + (error: unknown) => { + finish(response, error); + }, + ); + }; + const app = binden + ? new Binden().use(async (context: Context) => reply(context.response)) + : { + createServer: (): Server => + createServer((_request, response) => { + reply(response).catch(() => null); + }), + }; + const listening = await listen(app); + try { + const { status: code, body } = await send(listening.port); + return [ + // Counted once the response has been received + converted === null ? null : status.conversions(), + await settled.promise, + code, + body, + ]; + } finally { + await listening.close(); + } +} + +suite( + "An implicit commit converts a statusCode that is not a number once, as plain Node.js does, and Node.js gets that number (C28-2)", + () => { + for (const name of Object.keys(implicit)) { + test(name, async () => { + for (const converted of [201, 204, "a", null]) { + const node = await committed(false, name, converted); + deepStrictEqual( + await committed(true, name, converted), + node, + String(converted), + ); + // Once, and also for the message of a refusal (as `%s`) + deepStrictEqual( + node[0], + converted === null ? null : converted === "a" ? 2 : 1, + String(converted), + ); + } + }); + } + + test("once the head is committed, a send does not make a statusCode assigned later its number: it stays as assigned, as for plain Node.js", async () => { + const status = counting(200); + const kept = Promise.withResolvers(); + const app = new Binden().use(async (context: Context) => { + const { response } = context; + response.writeHead(200); + response.statusCode = status.value as never; + await response.send("abc"); + kept.resolve((response.statusCode as unknown) === status.value); + }); + const listening = await listen(app); + try { + const { status: code, body } = await send(listening.port); + deepStrictEqual([code, body, await kept.promise], [200, "abc", true]); + } finally { + await listening.close(); + } + }); + }, +); + +suite( + "writeEarlyHints(), addTrailers() and cookies.add() read their argument once (R27-R3)", + () => { + test("writeEarlyHints() reads the link and each other hint once, and Node.js gets the copy; without a link, nothing else is read", () => { + let links = 0; + const hints = { + get link(): string { + links += 1; + return links === 1 ? "; rel=preload" : "bad\n"; + }, + "x-a": "b", + }; + const informing = responseOf(true); + informing.writeEarlyHints(hints); + const queued = ( + Reflect.get(informing, "outputData") as { data: unknown }[] + ) + .map(({ data }) => String(data)) + .join(""); + ok(queued.includes("Link: ; rel=preload\r\n"), queued); + ok(queued.includes("x-a: b\r\n"), queued); + deepStrictEqual(links, 1); + let read = false; + const none = responseOf(true); + none.writeEarlyHints({ + link: null, + get "x-a"(): string { + read = true; + return "b"; + }, + } as never); + deepStrictEqual( + [read, (Reflect.get(none, "outputData") as unknown[]).length], + [false, 0], + ); + }); + + test("addTrailers() reads each pair (by its name and its value) and each trailer once, and Node.js gets the copy", () => { + const reads = new Map(); + const counted = (target: T, path: string): T => + new Proxy(target, { + get(object: T, key: PropertyKey): unknown { + if (typeof key === "string" && /^\d+$/u.test(key)) { + const at = `${path}.${key}`; + reads.set(at, (reads.get(at) ?? 0) + 1); + } + return Reflect.get(object, key); + }, + }); + const pairs = responseOf(true); + pairs.addTrailers( + counted( + [counted(["x-t", "v"], "pair"), ["x-u", ["1", "2"]]], + "pairs", + ) as never, + ); + deepStrictEqual( + [Reflect.get(pairs, "_trailer"), Object.fromEntries(reads)], + [ + "x-t: v\r\nx-u: 1\r\nx-u: 2\r\n", + { "pairs.0": 1, "pairs.1": 1, "pair.0": 1, "pair.1": 1 }, + ], + ); + // A pair that is not an object, which Node.js reads as it is (a + // string: by its characters) + const string = responseOf(true); + string.addTrailers(["xv"] as never); + deepStrictEqual(Reflect.get(string, "_trailer"), "x: v\r\n"); + let values = 0; + const object = responseOf(true); + object.addTrailers({ + get "x-t"(): string { + values += 1; + return values === 1 ? "v" : "bad\n"; + }, + }); + deepStrictEqual( + [Reflect.get(object, "_trailer"), values], + ["x-t: v\r\n", 1], + ); + }); + + test("cookies.add() refuses anything but an object (a Cookie, or one whose toString() gives its Set-Cookie), described and formatted, before it adds it; a sealed response ignores it", () => { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + const response = responseOf(true) as BindenResponse; + try { + for (const [given, described] of [ + [null, "null"], + [Reflect.get({}, "absent"), "undefined"], + ["a=b", "a=b"], + [Symbol("s"), "(a symbol)"], + [5, "5"], + [(): string => "a=b", "(a function)"], + ] as const) { + let thrown: unknown = null; + try { + response.cookies.add(given as never); + } catch (error: unknown) { + thrown = error; + } + ok(thrown instanceof TypeError, described); + deepStrictEqual( + [thrown.message, formatted.has(thrown)], + [`\`cookie\` is not an object: ${described}`, true], + ); + } + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + deepStrictEqual(response.cookies.size, 0); + const own = { toString: (): string => "a=b" }; + response.cookies.add(own as never); + deepStrictEqual([...response.cookies], [own]); + response.writeHead(200); + ok( + String(Reflect.get(response, "_header")).includes( + "Set-Cookie: a=b\r\n", + ), + ); + // Ignored once sealed: nothing is read nor refused + const sealed = responseOf(true) as BindenResponse; + sealed[kSeal](); + sealed.cookies.add(null as never); + deepStrictEqual(sealed.cookies.size, 0); + }); + }, +); 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 new file mode 100644 index 00000000..caccb153 --- /dev/null +++ b/packages/binden/test/headers/accept-encoding.test.ts @@ -0,0 +1,182 @@ +import { AcceptEncoding, type IAcceptEncodings } from "../../index.js"; +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; + +suite("AcceptEncoding", () => { + test("constructor", () => { + const encoding = "br"; + const q_value = 0.02; + const ac = new AcceptEncoding({ encoding, q_value }); + deepStrictEqual(ac.encoding, encoding); + deepStrictEqual(ac.q_value, q_value); + deepStrictEqual(ac.toString(), `${encoding};q=${q_value}`); + }); + + test("constructor (no `q_value`)", () => { + const encoding = "br"; + const ac = new AcceptEncoding({ encoding }); + deepStrictEqual(ac.encoding, encoding); + deepStrictEqual(ac.q_value, null); + deepStrictEqual(ac.toString(), encoding); + }); + + test("AcceptEncoding.fromString()", () => { + deepStrictEqual(AcceptEncoding.fromString(), []); + deepStrictEqual(AcceptEncoding.fromString(""), []); + deepStrictEqual(AcceptEncoding.fromString([]), []); + deepStrictEqual(AcceptEncoding.fromString([""]), []); + deepStrictEqual(AcceptEncoding.fromString(["", ""]), []); + + const input = + " deflate , gzip;q=0.3,x-gzip ;q= 0.4 , *;q=0.54 , identity;q=0.1, br;q=1,compress;q=0.7, ignored;q=2"; + const parsed = AcceptEncoding.fromString(input); + deepStrictEqual(parsed.length, 7); + + deepStrictEqual(parsed[0].encoding, "deflate"); + deepStrictEqual(parsed[0].q_value, null); + deepStrictEqual(parsed[0].toString(), "deflate"); + + deepStrictEqual(parsed[1].encoding, "br"); + deepStrictEqual(parsed[1].q_value, 1); + deepStrictEqual(parsed[1].toString(), "br;q=1"); + + deepStrictEqual(parsed[2].encoding, "compress"); + deepStrictEqual(parsed[2].q_value, 0.7); + deepStrictEqual(parsed[2].toString(), "compress;q=0.7"); + + deepStrictEqual(parsed[3].encoding, "*"); + deepStrictEqual(parsed[3].q_value, 0.54); + deepStrictEqual(parsed[3].toString(), "*;q=0.54"); + + deepStrictEqual(parsed[4].encoding, "x-gzip"); + deepStrictEqual(parsed[4].q_value, 0.4); + deepStrictEqual(parsed[4].toString(), "x-gzip;q=0.4"); + + deepStrictEqual(parsed[5].encoding, "gzip"); + deepStrictEqual(parsed[5].q_value, 0.3); + deepStrictEqual(parsed[5].toString(), "gzip;q=0.3"); + + deepStrictEqual(parsed[6].encoding, "identity"); + deepStrictEqual(parsed[6].q_value, 0.1); + 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", + ); + + deepStrictEqual( + parsed.map((e) => e.toString()), + ["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); + } + }); + + 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, + -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/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..a5ee38f4 --- /dev/null +++ b/packages/binden/test/headers/accept.test.ts @@ -0,0 +1,251 @@ +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 names are + // case-insensitive, and so is the value of `charset` only + 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=A", "text/html;level=a"), false); + deepStrictEqual( + negotiate( + `application/x;profile="https://a.test/A"`, + `application/x;profile="https://a.test/a"`, + ), + false, + ); + deepStrictEqual( + negotiate("text/plain;CHARSET=UTF-8", "text/plain;charset=utf-8"), + "text/plain;charset=utf-8", + ); + 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/headers/authorization.test.ts b/packages/binden/test/headers/authorization.test.ts new file mode 100644 index 00000000..79576071 --- /dev/null +++ b/packages/binden/test/headers/authorization.test.ts @@ -0,0 +1,97 @@ +import { Authorization, type IAuthenticationType } from "../../index.js"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { suite, test } from "node:test"; + +suite("Authorization", () => { + test("constructor", () => { + const type = "Bearer"; + const credentials = "credentials"; + const authorization = new Authorization({ type, credentials }); + deepStrictEqual(authorization.type, type); + deepStrictEqual(authorization.credentials, credentials); + deepStrictEqual(authorization.toString(), `${type} ${credentials}`); + }); + + test("constructor (no `credentials`)", () => { + const type = "AWS4-HMAC-SHA256"; + const authorization = new Authorization({ type }); + deepStrictEqual(authorization.type, type); + deepStrictEqual(authorization.credentials, null); + 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); + deepStrictEqual(Authorization.fromString("invalittype"), null); + const input = "Bearer credentials"; + const parsed = Authorization.fromString(input); + ok(parsed); + deepStrictEqual(parsed.type, "Bearer"); + 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); + // Credentials that the constructor refuses + deepStrictEqual(parse("Bearer a\u0001b"), 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()); + }); +}); diff --git a/packages/binden/test/headers/content-encoding.test.ts b/packages/binden/test/headers/content-encoding.test.ts new file mode 100644 index 00000000..8e6d450d --- /dev/null +++ b/packages/binden/test/headers/content-encoding.test.ts @@ -0,0 +1,46 @@ +import { ContentEncoding, type IEncodings } from "../../index.js"; +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; + +suite("ContentEncoding", () => { + test("constructor", () => { + const encoding = "br"; + const ac = new ContentEncoding({ encoding }); + deepStrictEqual(ac.encoding, encoding); + 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; + + 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", + ]); + // 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); + } + }); +}); diff --git a/test/headers/content-range.test.ts b/packages/binden/test/headers/content-range.test.ts similarity index 75% rename from test/headers/content-range.test.ts rename to packages/binden/test/headers/content-range.test.ts index 4ad052c1..aecebb32 100644 --- a/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"; @@ -38,9 +39,20 @@ 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"); + // Not `bytes */*`: an unsatisfied range has a size + throws( + () => new ContentRange({ size: "*" }), + new TypeError("An unsatisfied range needs a `size`"), + ); + throws( + () => new ContentRange({ start: null, end: null, size: "*" }), + new TypeError("An unsatisfied range needs a `size`"), + ); throws( () => new ContentRange({ size, end: size }), new TypeError("`end` is greater than `size`"), @@ -88,10 +100,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; diff --git a/packages/binden/test/headers/content-type.test.ts b/packages/binden/test/headers/content-type.test.ts new file mode 100644 index 00000000..d7ea406e --- /dev/null +++ b/packages/binden/test/headers/content-type.test.ts @@ -0,0 +1,263 @@ +import { deepStrictEqual, ok, throws } from "node:assert"; +import { suite, test } from "node:test"; + +import { ContentType } from "../../index.js"; + +suite("ContentType", () => { + test("constructor", () => { + const type = "video/mp4"; + const ct = new ContentType({ type }); + deepStrictEqual(ct.type, type); + deepStrictEqual(ct.charset, null); + deepStrictEqual(ct.boundary, null); + deepStrictEqual(ct.toString(), type); + + throws( + () => new ContentType({ type: "multipart/form-data" }), + new TypeError("`boundary` is missing"), + ); + }); + + test("constructor (`multipart/form-data` in any case)", () => { + // A media type is case-insensitive (RFC 9110, section 8.3.1) + for (const type of ["Multipart/Form-Data", "MULTIPART/FORM-DATA"]) { + throws( + () => new ContentType({ type }), + new TypeError("`boundary` is missing"), + type, + ); + const ct = new ContentType({ type, boundary: "b", charset: "utf-8" }); + deepStrictEqual(ct.boundary, "b"); + deepStrictEqual(ct.charset, null); + deepStrictEqual(ct.toString(), `${type}; boundary=b`); + // Parsed back + deepStrictEqual(ContentType.fromString(ct.toString())?.parameters, { + boundary: "b", + }); + } + }); + + test("constructor(with charset)", () => { + const type = "text/plain"; + const charset = "UTF-8"; + const boundary = "__boundary"; + const ct = new ContentType({ type, charset, boundary }); + deepStrictEqual(ct.type, type); + deepStrictEqual(ct.charset, charset); + deepStrictEqual(ct.boundary, null); + deepStrictEqual(ct.toString(), `${type}; charset=${charset}`); + }); + + test("constructor(with boundary)", () => { + const type = "multipart/form-data"; + const charset = "UTF-8"; + const boundary = "__boundary"; + const ct = new ContentType({ type, charset, boundary }); + deepStrictEqual(ct.type, type); + deepStrictEqual(ct.charset, null); + deepStrictEqual(ct.boundary, boundary); + deepStrictEqual(ct.toString(), `${type}; boundary=${boundary}`); + }); + + test("ContentType.fromString()", () => { + const input = ` text/html `; + const parsed = ContentType.fromString(input); + ok(parsed); + deepStrictEqual(parsed.type, "text/html"); + deepStrictEqual(parsed.charset, null); + deepStrictEqual(parsed.boundary, null); + deepStrictEqual(parsed.toString(), "text/html"); + }); + + test("ContentType.fromString() (charset)", () => { + const input = ` text/html ; charset = utf-8 `; + const parsed = ContentType.fromString(input); + ok(parsed); + deepStrictEqual(parsed.type, "text/html"); + deepStrictEqual(parsed.charset, "utf-8"); + deepStrictEqual(parsed.boundary, null); + deepStrictEqual(parsed.toString(), "text/html; charset=utf-8"); + }); + + test("ContentType.fromString() (with quoted charset)", () => { + const input = ` text/html ; charset = "UtF-8" `; + const parsed = ContentType.fromString(input); + ok(parsed); + deepStrictEqual(parsed.type, "text/html"); + deepStrictEqual(parsed.charset, "utf-8"); + deepStrictEqual(parsed.boundary, null); + deepStrictEqual(parsed.toString(), "text/html; charset=utf-8"); + }); + + test("ContentType.fromString() (with boudary)", () => { + const input = ` multipart/form-data ; boundary = something `; + const parsed = ContentType.fromString(input); + ok(parsed); + deepStrictEqual(parsed.type, "multipart/form-data"); + deepStrictEqual(parsed.charset, null); + deepStrictEqual(parsed.boundary, "something"); + deepStrictEqual( + parsed.toString(), + "multipart/form-data; boundary=something", + ); + }); + + test("ContentType.fromString() (with empty charset)", () => { + const input = `text/plain; charset=""`; + const parsed = ContentType.fromString(input); + ok(parsed); + deepStrictEqual(parsed.type, "text/plain"); + deepStrictEqual(parsed.boundary, null); + deepStrictEqual(parsed.charset, null); + deepStrictEqual(parsed.toString(), "text/plain"); + }); + + test("ContentType.fromString() (invalid input)", () => { + deepStrictEqual(ContentType.fromString(), null); + deepStrictEqual(ContentType.fromString(""), null); + 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`, + ); + }); + + 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("constructor (values that a field value cannot carry)", () => { + const type = "text/plain"; + // Above `\xFF`: `setHeader()` would refuse the serialization + for (const value of ["\u0100", "a\u{1F600}", "\u2028", "\0", "\x7F"]) { + throws( + () => new ContentType({ type, parameters: { a: value } }), + new TypeError("Invalid value of the parameter a"), + JSON.stringify(value), + ); + throws( + () => new ContentType({ type, charset: value }), + new TypeError("Invalid value of the parameter charset"), + ); + throws( + () => new ContentType({ type: "multipart/form-data", boundary: value }), + new TypeError("Invalid value of the parameter boundary"), + ); + deepStrictEqual(ContentType.fromString(`${type}; a="${value}"`), null); + } + // obs-text and tabs are field values + const ct = new ContentType({ type, parameters: { a: "\xE9\t\xFF" } }); + deepStrictEqual(ct.toString(), `${type}; a="\xE9\t\xFF"`); + deepStrictEqual(ContentType.fromString(ct.toString())?.parameters, { + a: "\xE9\t\xFF", + }); + }); + + 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/converted-values.test.ts b/packages/binden/test/headers/converted-values.test.ts new file mode 100644 index 00000000..86a3a31b --- /dev/null +++ b/packages/binden/test/headers/converted-values.test.ts @@ -0,0 +1,202 @@ +import { + Accept, + AcceptCharset, + AcceptEncoding, + AcceptLanguage, + Authorization, + ContentType, + Cookie, +} from "../../index.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; + +/** + * A value of JavaScript in place of a string (or of a number), whose + * `toString()` tells `first` on its first call, and `later` afterwards: how + * many times it has been called + */ +interface IFlip { + readonly value: unknown; + calls(): number; +} + +/** A value whose `toString()` changes once called (see `IFlip`) */ +function flip(first: string, later: string): IFlip { + let calls = 0; + return { + value: { + toString(): string { + calls += 1; + return calls === 1 ? first : later; + }, + }, + calls: () => calls, + }; +} + +/** A value of any type, given in place of the one that an init declares */ +function given(value: unknown): T { + return value as T; +} + +suite( + "A header constructed from values that are not strings keeps the strings that it has checked, each converted once (R3 P2-4)", + () => { + for (const [name, create, [first, later], expected] of [ + [ + "Cookie (its name)", + (value: unknown): unknown => + new Cookie({ key: given(value), value: "v" }), + ["a", "a=b; Domain=evil.example; x"], + "a=v; Path=/; HttpOnly; SameSite=Lax", + ], + [ + "Cookie (its value)", + (value: unknown): unknown => + new Cookie({ key: "session", value: given(value) }), + ["v", "v; Domain=evil.example"], + "session=v; Path=/; HttpOnly; SameSite=Lax", + ], + [ + "Cookie (its Domain)", + (value: unknown): unknown => + new Cookie({ key: "a", value: "v", domain: given(value) }), + ["example.com", "evil.example; Partitioned"], + "a=v; Domain=example.com; Path=/; HttpOnly; SameSite=Lax", + ], + [ + "Cookie (its Path)", + (value: unknown): unknown => + new Cookie({ key: "a", value: "v", path: given(value) }), + ["/app", "/; Domain=evil.example"], + "a=v; Path=/app; HttpOnly; SameSite=Lax", + ], + [ + "Accept (its media range)", + (value: unknown): unknown => new Accept({ type: given(value) }), + ["text/html", "text/html, */*"], + "text/html", + ], + [ + "Accept (the value of a parameter)", + (value: unknown): unknown => + new Accept({ + type: "text/html", + parameters: { level: given(value) }, + }), + ["1", "1, */*;q=1"], + "text/html;level=1", + ], + [ + "ContentType (its media type)", + (value: unknown): unknown => new ContentType({ type: given(value) }), + ["text/plain", "text/plain; charset=evil"], + "text/plain", + ], + [ + "ContentType (the value of a parameter)", + (value: unknown): unknown => + new ContentType({ + type: "text/plain", + parameters: { format: given(value) }, + }), + ["flowed", "x\r\nX-Injected: 1"], + "text/plain; format=flowed", + ], + [ + "AcceptCharset (its charset)", + (value: unknown): unknown => + new AcceptCharset({ charset: given(value) }), + ["utf-8", "utf-8, evil"], + "utf-8", + ], + [ + "AcceptLanguage (its language range)", + (value: unknown): unknown => + new AcceptLanguage({ language: given(value) }), + ["en", "en, evil"], + "en", + ], + [ + "Authorization (its credentials)", + (value: unknown): unknown => + new Authorization({ type: "Bearer", credentials: given(value) }), + ["token", "token, Basic evil"], + "Bearer token", + ], + ] as const) { + test(name, () => { + const value = flip(first, later); + const header = create(value.value); + // Kept as checked, however many times it is serialized + deepStrictEqual( + [String(header), String(header), value.calls()], + [expected, expected, 1], + ); + }); + } + }, +); + +suite( + "A value of a header that cannot be converted to a string, or a weight that is not a number, is refused with a TypeError (R3 P2-4)", + () => { + test("a Symbol, or an object without a primitive value: a TypeError formatted at once", () => { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + formatted.add(error); + return [error.message, ...frames.map(String)].join("\n"); + }, + ); + try { + for (const [value, message] of [ + [Symbol("v"), "Cannot convert a Symbol value to a string"], + [Object.create(null) as object, null], + ] as const) { + let caught: unknown = null; + try { + new Cookie({ key: "a", value: given(value) }).toString(); + } catch (error: unknown) { + caught = error; + } + ok(caught instanceof TypeError); + ok(formatted.has(caught)); + if (message !== null) { + deepStrictEqual(caught.message, message); + } + } + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + }); + + test("a weight of Accept, AcceptCharset, AcceptLanguage or AcceptEncoding that is not a number, never converted", () => { + for (const create of [ + (q_value: unknown): unknown => + new Accept({ type: "text/html", q_value: given(q_value) }), + (q_value: unknown): unknown => + new AcceptCharset({ charset: "utf-8", q_value: given(q_value) }), + (q_value: unknown): unknown => + new AcceptLanguage({ language: "en", q_value: given(q_value) }), + (q_value: unknown): unknown => + new AcceptEncoding({ encoding: "gzip", q_value: given(q_value) }), + ]) { + for (const q_value of ["0.5", flip("0.5", "1, evil")]) { + const value = typeof q_value === "string" ? q_value : q_value.value; + throws(() => create(value), { + name: "TypeError", + message: "`q_value` is not a number", + }); + if (typeof q_value !== "string") { + deepStrictEqual(q_value.calls(), 0); + } + } + deepStrictEqual(String(create(0.5)).endsWith(";q=0.5"), true); + } + }); + }, +); diff --git a/test/headers/cookie.test.ts b/packages/binden/test/headers/cookie.test.ts similarity index 62% rename from test/headers/cookie.test.ts rename to packages/binden/test/headers/cookie.test.ts index 9c0a9c51..f493d9ff 100644 --- a/test/headers/cookie.test.ts +++ b/packages/binden/test/headers/cookie.test.ts @@ -1,4 +1,5 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; import { Cookie } from "../../index.js"; @@ -125,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" }); @@ -133,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; @@ -159,4 +173,89 @@ 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"], + ["empty", ""], + ], + ); + // An invalid cookie does not hide a valid one of its name + deepStrictEqual( + Cookie.fromString(`c=x\u0001; c=ok`).map(({ key, value }) => [ + key, + value, + ]), + [["c", "ok"]], + ); + }); + + test(".fromString() is linear in the number of cookies", (t) => { + const count = 2000; + const input = Array.from( + { length: count }, + (_, index) => `k${index}=`, + ).join(";"); + // The names of the cookies found so far are never searched among them + const key = t.mock.getter(Cookie.prototype, "key"); + try { + deepStrictEqual(Cookie.fromString(input).length, count); + deepStrictEqual(key.mock.callCount() <= count, true); + } finally { + key.mock.restore(); + } + }); }); diff --git a/packages/binden/test/headers/forwarded.test.ts b/packages/binden/test/headers/forwarded.test.ts new file mode 100644 index 00000000..bd6680cb --- /dev/null +++ b/packages/binden/test/headers/forwarded.test.ts @@ -0,0 +1,182 @@ +import { Forwarded, type IForwarded } from "../../index.js"; +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; + +suite("Forwarded", () => { + test("constructor", () => { + const _ = { for: "[2001:db8:cafe::17]:4711" }; + const by = "203.0.113.43"; + const secret = "Secret"; + const host = "example.com"; + const proto = "http"; + + const forwarded = new Forwarded({ ..._, by, host, secret, proto }); + deepStrictEqual(forwarded.for, _.for); + deepStrictEqual(forwarded.by, by); + deepStrictEqual(forwarded.host, host); + deepStrictEqual(forwarded.proto, proto); + deepStrictEqual(forwarded.secret, secret); + deepStrictEqual( + forwarded.toString(), + `for="${_.for}";by=${by};host=${host};secret=${secret};proto=${proto}`, + ); + }); + + 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=", 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=""`; + + const forwarded1 = new Forwarded({ + for: "[2001:db8:cafe::17]:4711", + proto: "https", + }); + const forwarded2 = new Forwarded({ + for: "192.0.2.60", + proto: "http", + by: "203.0.113.43", + secret: "SECRET", + host: "localhost", + }); + + const parsed = Forwarded.fromString(input); + + // `ignored=1` and `for=""` are valid elements, while `ignored` (not a + // parameter) and `for="` (an unterminated quoted string, which swallows + // what is before it only) are not + deepStrictEqual(parsed.length, 4); + + const [one, two, extension, empty] = parsed; + deepStrictEqual(extension.toString(), "for=unknown"); + deepStrictEqual(empty.for, ""); + + deepStrictEqual(one.for, forwarded1.for); + deepStrictEqual(one.by, forwarded1.by); + deepStrictEqual(one.host, forwarded1.host); + deepStrictEqual(one.proto, forwarded1.proto); + deepStrictEqual(one.secret, forwarded1.secret); + deepStrictEqual(one.toString(), forwarded1.toString()); + + deepStrictEqual(two.for, forwarded2.for); + deepStrictEqual(two.by, forwarded2.by); + deepStrictEqual(two.host, forwarded2.host); + deepStrictEqual(two.proto, forwarded2.proto); + deepStrictEqual(two.secret, forwarded2.secret); + 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"`, + ]); + // 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]); + }); + + test(".parse() (a field line is read from its end)", () => { + const parse = (input?: readonly string[] | string): (string | null)[] => + Forwarded.parse(input).map((element) => element?.toString() ?? null); + + // An unterminated quoted string (e.g. of a client) swallows the elements + // before it, never those that the proxies append after it + deepStrictEqual(parse(`for=a, for="b, for=c;proto=https, for=d`), [ + null, + "for=c;proto=https", + "for=d", + ]); + deepStrictEqual(parse(`for=a, for=b"`), [null]); + // Escaped backslashes, quotes and separators in a quoted string + deepStrictEqual(parse(`for=a, for="b\\\\", for=c`), [ + "for=a", + `for="b\\\\"`, + "for=c", + ]); + deepStrictEqual(parse(`for="a,\\"b", for=c`), [`for="a,\\"b"`, "for=c"]); + // A quote after an even number of backslashes is not escaped + deepStrictEqual(parse(`for=x, a\\\\"b, c", for=y`), [ + "for=x", + null, + "for=y", + ]); + // Every field line apart, in their order + deepStrictEqual(parse([`for="x`, "for=192.0.2.1", `for=a, for="y`]), [ + null, + "for=192.0.2.1", + null, + ]); + deepStrictEqual(parse([]), []); + }); + + test(".fromString() (no `for` directive)", () => { + const secret = "donottellanyone"; + const input = `Secret = donottellanyone ; , ; , `; + + const forwarded = new Forwarded({ for: "unknown", secret }); + + const parsed = Forwarded.fromString(input); + + deepStrictEqual(parsed.length, 1); + + const [actual] = parsed; + + deepStrictEqual(actual.for, forwarded.for); + deepStrictEqual(actual.by, forwarded.by); + deepStrictEqual(actual.host, forwarded.host); + deepStrictEqual(actual.proto, forwarded.proto); + deepStrictEqual(actual.secret, forwarded.secret); + deepStrictEqual(actual.toString(), forwarded.toString()); + }); +}); 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..0bbbb283 --- /dev/null +++ b/packages/binden/test/headers/http-date.test.ts @@ -0,0 +1,67 @@ +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert"; +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("the years 0 to 99 of four digits", () => { + for (const [input, iso] of [ + ["Sat, 01 Jan 0000 00:00:00 GMT", "0000-01-01T00:00:00.000Z"], + ["Sun, 06 Nov 0099 08:49:37 GMT", "0099-11-06T08:49:37.000Z"], + ["Thu Nov 6 08:49:37 0050", "0050-11-06T08:49:37.000Z"], + ] as const) { + deepStrictEqual(parseHttpDate(input)?.toISOString(), iso, input); + } + // February 29 of a leap year, not of 1900 + deepStrictEqual( + parseHttpDate("Tue, 29 Feb 0000 00:00:00 GMT")?.toISOString(), + "0000-02-29T00:00:00.000Z", + ); + deepStrictEqual(parseHttpDate("Thu, 29 Feb 0001 00:00:00 GMT"), null); + }); + + 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 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 new file mode 100644 index 00000000..77846f7d --- /dev/null +++ b/packages/binden/test/headers/if-modified-since.test.ts @@ -0,0 +1,54 @@ +import { deepStrictEqual, ok, throws } from "node:assert"; +import { suite, test } from "node:test"; + +import { IfModifiedSince } from "../../index.js"; + +suite("If-Modified-Since", () => { + test("constructor", () => { + const date = new Date(); + const ac = new IfModifiedSince({ date }); + deepStrictEqual(ac.date, date); + deepStrictEqual(ac.toString(), date.toUTCString()); + }); + + test("constructor (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, 50, 99, 9999]) { + const date = new Date(Date.UTC(2000, 0, 1)); + date.setUTCFullYear(year); + const header = new IfModifiedSince({ date }); + deepStrictEqual(header.date, date); + // Parsed back, also before the year 100 + deepStrictEqual( + IfModifiedSince.fromString(header.toString())?.date, + date, + ); + } + }); + + test("IfModifiedSince.fromString()", () => { + 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); + deepStrictEqual(parsed.date, new Date(input.trim())); + deepStrictEqual(parsed.toString(), new Date(input.trim()).toUTCString()); + }); +}); diff --git a/packages/binden/test/headers/invalid-elements.test.ts b/packages/binden/test/headers/invalid-elements.test.ts new file mode 100644 index 00000000..b1fc2a88 --- /dev/null +++ b/packages/binden/test/headers/invalid-elements.test.ts @@ -0,0 +1,106 @@ +import { + Accept, + AcceptCharset, + AcceptLanguage, + Authorization, + ContentRange, + ContentType, + Cookie, + Forwarded, +} from "../../index.js"; +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; + +/** + * Runs `run` while `Error.prepareStackTrace` counts the errors whose stacks + * V8 formats (every error that binden creates is formatted at once): what + * `run` returns, and how many errors it has created + */ +function counting(run: () => T): [T, number] { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + let count = 0; + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + count += 1; + return `${error.name}: ${error.message}`; + }); + try { + return [run(), count]; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +suite( + "A header whose elements are invalid (e.g. of a client) is parsed without an error for each", + () => { + for (const [name, parse, valid] of [ + [ + "Cookie", + (): unknown[] => Cookie.fromString('a=,; b="x y"; c d=1; e=1'), + 1, + ], + [ + "Accept", + (): unknown[] => + Accept.fromString("x, */html, a/b;q=2, a/b;q=1;c=d, a/b;=1, a/b"), + 2, + ], + [ + "Accept-Charset", + (): unknown[] => AcceptCharset.fromString("@, utf-8"), + 1, + ], + [ + "Accept-Language", + (): unknown[] => AcceptLanguage.fromString("-, en-123456789, en"), + 1, + ], + [ + "Forwarded", + (): unknown[] => + Forwarded.parse('for="\\\u0001", for=a').filter( + (element) => element !== null, + ), + 1, + ], + [ + "Content-Type", + (): unknown[] => + [ + "x", + "multipart/form-data", + "text/plain; a=\u0001", + "text/plain; charset=utf-8", + ] + .map((input) => ContentType.fromString(input)) + .filter((element) => element !== null), + 1, + ], + [ + "Content-Range", + (): unknown[] => + [ + "bytes 5-1/9", + "bytes 0-9/5", + "bytes 0-99999999999999999999/1", + "bytes */99999999999999999999", + "bytes 0-1/9", + ].flatMap((input) => ContentRange.fromString(input)), + 1, + ], + [ + "Authorization", + (): unknown[] => + ["Basic a\u0001b", "Basic abc=="] + .map((input) => Authorization.fromString(input)) + .filter((element) => element !== null), + 1, + ], + ] as const) { + test(name, () => { + const [elements, created] = counting(parse); + deepStrictEqual([elements.length, created], [valid, 0]); + }); + } + }, +); diff --git a/packages/binden/test/headers/linear.test.ts b/packages/binden/test/headers/linear.test.ts new file mode 100644 index 00000000..24398d6f --- /dev/null +++ b/packages/binden/test/headers/linear.test.ts @@ -0,0 +1,146 @@ +import { + AcceptEncoding, + Authorization, + BindenRequest, + BindenResponse, + Range, +} from "../../index.js"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { suite, test } from "node:test"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; +import { Worker } from "node:worker_threads"; +import { once } from "node:events"; + +/** + * The milliseconds of CPU time (user and system) that the current thread has + * taken since `start`, a `process.threadCpuUsage()`: unlike the time of the + * clock, it does not grow while other processes load the machine, nor with + * the threads of V8 that compile and collect in the background (which + * `process.cpuUsage()`, of the whole process, counts) + */ +function cpuSince(start: NodeJS.CpuUsage): number { + const { user, system } = process.threadCpuUsage(start); + return (user + system) / 1000; +} + +/** The milliseconds of CPU time that `fn` takes (see `cpuSince()`) */ +function duration(fn: () => unknown): number { + const start = process.threadCpuUsage(); + fn(); + return cpuSince(start); +} + +/** + * The length of a header that Node.js accepts (`maxHeaderSize`, 16 KiB): a + * parser that backtracks polynomially on it takes hundreds of milliseconds + */ +const length = 15_000; + +/** A bound (of CPU time) far above a linear parse, far below a quadratic one */ +const bound = 100; + +/** + * The message of a fixture (`test/fixtures`) run in a worker thread, which is + * terminated after `ms` milliseconds: an exponential backtracking (that a test + * in the same thread could not stop) fails the test instead of hanging it + */ +async function inWorker(fixture: string, ms: number): Promise { + const worker = new Worker(new URL(`../fixtures/${fixture}`, import.meta.url)); + const timer = setTimeout(() => { + worker.terminate().catch(() => null); + }, ms); + try { + const [message] = (await Promise.race([ + once(worker, "message"), + once(worker, "exit").then(() => [new Error("Terminated")]), + ])) as [unknown]; + return message; + } finally { + clearTimeout(timer); + await worker.terminate(); + } +} + +suite("Header parsers in linear time", () => { + test("Authorization: a scheme followed by a long run of spaces", () => { + // A line terminator, which `.` does not match, after the spaces + const input = `! ${" ".repeat(length)}\u2028x`; + ok(duration(() => Authorization.fromString(input)) < bound); + deepStrictEqual(Authorization.fromString(input), null); + deepStrictEqual( + Authorization.fromString("Basic abc")?.toString(), + "Basic abc", + ); + }); + + test("Range: long runs of tabs around an empty position", () => { + for (const input of [ + `bytes=${"\t".repeat(length)}x`, + `bytes=-${"\t".repeat(length)}x`, + ]) { + ok(duration(() => Range.fromString(input)) < bound); + deepStrictEqual(Range.fromString(input), []); + } + deepStrictEqual(Range.fromString("bytes= \t1 - 2 \t, \t-5").map(String), [ + "bytes=1-2", + "bytes=-5", + ]); + }); + + test("Accept-Encoding: a weight followed by a long run of spaces", () => { + const input = `gzip;q=${" ".repeat(length)}x`; + ok(duration(() => AcceptEncoding.fromString(input)) < bound); + deepStrictEqual(AcceptEncoding.fromString(input), []); + deepStrictEqual(AcceptEncoding.fromString("gzip; Q = 0.5").map(String), [ + "gzip;q=0.5", + ]); + }); + + test("response.type(): long runs of ` ;`, whitespace and escapes", async () => { + const results = (await inWorker("linear-media-type.ts", 20_000)) as { + outcome: string; + ms: number; + }[]; + deepStrictEqual( + results.map(({ outcome }) => outcome), + [ + "TypeError", + "TypeError", + "TypeError", + "TypeError", + "TypeError", + "TypeError", + ], + ); + for (const { ms } of results) { + ok(ms < bound, `${ms} ms`); + } + // The grammar is unchanged + const response = new BindenResponse(new IncomingMessage(new Socket())); + deepStrictEqual( + response + .type(' \tText/Plain \t; ;\ta=b ; c="d;\\"e" \t') + .getHeader("Content-Type"), + 'text/plain; a=b; c="d;\\"e"', + ); + }); + + test("an absolute-form request-target with a line terminator after a long path", () => { + const request = new BindenRequest(new Socket()); + request.headers = { host: "localhost" }; + request.url = `http://${"a".repeat(length / 2)}/${"b".repeat(length / 2)}\n`; + let ms = Number.POSITIVE_INFINITY; + throws(() => { + const start = process.threadCpuUsage(); + try { + return request.URL; + } finally { + ms = cpuSince(start); + } + }, /Bad Request/u); + ok(ms < bound, `${ms} ms`); + request.url = "http://example.test:8080?a=b"; + deepStrictEqual(request.URL.href, "http://example.test:8080/?a=b"); + }); +}); diff --git a/test/headers/range.test.ts b/packages/binden/test/headers/range.test.ts similarity index 50% rename from test/headers/range.test.ts rename to packages/binden/test/headers/range.test.ts index 4dc785aa..9c2968aa 100644 --- a/test/headers/range.test.ts +++ b/packages/binden/test/headers/range.test.ts @@ -1,4 +1,5 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; import { Range } from "../../index.js"; @@ -14,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()", () => { @@ -78,4 +87,33 @@ 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"]); + // 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 [ + "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)); + } + }); }); diff --git a/packages/binden/test/headers/read-once.test.ts b/packages/binden/test/headers/read-once.test.ts new file mode 100644 index 00000000..f04b18ea --- /dev/null +++ b/packages/binden/test/headers/read-once.test.ts @@ -0,0 +1,326 @@ +import { + Accept, + AcceptCharset, + AcceptEncoding, + AcceptLanguage, + Authorization, + ContentEncoding, + ContentRange, + ContentType, + Cookie, + Forwarded, + IfModifiedSince, + Range, +} from "../../index.js"; +import { deepStrictEqual, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readdirSync } from "node:fs"; + +/** The reads of each property of an init (or of its parameters) */ +type IReads = Map; + +/** + * A dictionary whose properties each give the value of `second` from their + * second read on (e.g. a getter of the application, every one of them + * changing), and that counts the reads of each one in `reads`, as + * `name.property` + */ +function changing( + name: string, + first: T, + second: { readonly [K in keyof T]-?: unknown }, + reads: IReads, +): T { + return new Proxy(first, { + get: (target, key, receiver): unknown => { + const read = `${name}.${String(key)}`; + const count = (reads.get(read) ?? 0) + 1; + reads.set(read, count); + return count > 1 && Object.hasOwn(second, key) + ? (second as Readonly>)[key] + : Reflect.get(target, key, receiver); + }, + }); +} + +/** + * The names of the classes of the headers: the default export of each module + * of `src/headers/` that has one (none of its helpers has) + */ +async function headerClasses(): Promise { + const directory = join(import.meta.dirname, "..", "..", "src", "headers"); + const names: string[] = []; + for (const file of readdirSync(directory)) { + const { default: header } = (await import( + pathToFileURL(join(directory, file)).href + )) as { readonly default?: unknown }; + if (typeof header === "function") { + names.push(header.name); + } + } + return names; +} + +/** + * Each header class: a header constructed from an init (and parameters, for + * the classes that have them) whose every value changes once it is read, and + * the field value that it gives + */ +const cases: readonly (readonly [ + { readonly name: string }, + (reads: IReads) => unknown, + string, +])[] = [ + [ + Accept, + (reads): unknown => + new Accept( + changing( + "init", + { + type: "text/html", + parameters: changing( + "parameters", + { level: "1" }, + { level: "1, */evil" }, + reads, + ), + q_value: 0.5, + }, + { type: "a/b, */evil", parameters: { level: "2" }, q_value: 0.9 }, + reads, + ), + ), + "text/html;level=1;q=0.5", + ], + [ + AcceptCharset, + (reads): unknown => + new AcceptCharset( + changing( + "init", + { charset: "utf-8", q_value: 0.5 }, + { charset: "utf-8, evil;q=2", q_value: 0.9 }, + reads, + ), + ), + "utf-8;q=0.5", + ], + [ + AcceptEncoding, + (reads): unknown => + new AcceptEncoding( + changing( + "init", + { encoding: "gzip", q_value: 0.5 } as const, + { encoding: "br", q_value: 0.9 }, + reads, + ), + ), + "gzip;q=0.5", + ], + [ + AcceptLanguage, + (reads): unknown => + new AcceptLanguage( + changing( + "init", + { language: "en", q_value: 0.5 }, + { language: "en, evil", q_value: 0.9 }, + reads, + ), + ), + "en;q=0.5", + ], + [ + Authorization, + (reads): unknown => + new Authorization( + changing( + "init", + { type: "Bearer", credentials: "abc" } as const, + { type: "Basic", credentials: "abc\r\nInjected: 1" }, + reads, + ), + ), + "Bearer abc", + ], + [ + ContentEncoding, + (reads): unknown => + new ContentEncoding( + changing( + "init", + { encoding: "gzip" } as const, + { encoding: "br" }, + reads, + ), + ), + "gzip", + ], + [ + ContentRange, + (reads): unknown => + new ContentRange( + changing( + "init", + { start: 0, end: 9, size: 10 }, + { start: 5, end: 99, size: 100 }, + reads, + ), + ), + "bytes 0-9/10", + ], + [ + ContentType, + (reads): unknown => + new ContentType( + changing( + "init", + { + type: "text/plain", + charset: "utf-8", + boundary: "ignored", + parameters: changing( + "parameters", + { format: "flowed" }, + { format: "x\r\nInjected: 1" }, + reads, + ), + }, + { + type: "text/html; evil", + charset: "x\r\nInjected: 1", + boundary: "other", + parameters: { format: "fixed" }, + }, + reads, + ), + ), + "text/plain; format=flowed; charset=utf-8", + ], + [ + Cookie, + (reads): unknown => + new Cookie( + changing( + "init", + { + key: "sid", + value: "abc", + expires: new Date(0), + max_age: null, + domain: "example.com", + path: "/", + secure: true, + http_only: true, + same_site: "Strict", + partitioned: false, + } as const, + { + key: "sid=x; Secure; a", + value: "x; Domain=evil.example", + expires: new Date(1e12), + max_age: 60, + domain: "evil.example; Partitioned", + path: "/; Domain=evil.example", + secure: false, + http_only: false, + same_site: "None", + partitioned: true, + }, + reads, + ), + ), + "sid=abc; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Strict", + ], + [ + Forwarded, + (reads): unknown => + new Forwarded( + changing( + "init", + { + for: "192.0.2.1", + by: "192.0.2.2", + host: "example.com", + secret: "_hidden", + proto: "https", + }, + { + for: 'x", by="evil', + by: "\u0001", + host: "evil.example, for=x", + secret: "_other", + proto: "\u0001", + }, + reads, + ), + ), + "for=192.0.2.1;by=192.0.2.2;host=example.com;secret=_hidden;proto=https", + ], + [ + IfModifiedSince, + (reads): unknown => + new IfModifiedSince( + changing( + "init", + { date: new Date(0) }, + { date: new Date(1e12) }, + reads, + ), + ), + "Thu, 01 Jan 1970 00:00:00 GMT", + ], + [ + Range, + (reads): unknown => + new Range( + changing("init", { start: 0, end: 9 }, { start: 5, end: 1 }, reads), + ), + "bytes=0-9", + ], +]; + +const classes = await headerClasses(); + +suite( + "A header constructed from an init whose values change once they are read keeps the values that it has checked, each read once", + () => { + test("every header class has a case (each module of `src/headers/` that exports one)", () => { + deepStrictEqual( + cases.map(([header]) => header.name).toSorted(), + classes.toSorted(), + ); + }); + + for (const [{ name }, create, expected] of cases) { + test(name, () => { + const reads: IReads = new Map(); + const header = String(create(reads)); + deepStrictEqual( + [header, [...reads].filter(([, count]) => count !== 1)], + [expected, []], + ); + }); + } + + test("Cookie (its `Expires`: the copy that it keeps is the date checked)", () => { + // A date whose method tells another time than its own + const lying = (time: number): Date => + Object.assign(new Date(time), { getTime: (): number => 0 }); + throws( + () => new Cookie({ key: "a", value: "b", expires: lying(Number.NaN) }), + { name: "TypeError", message: "Invalid cookie Expires" }, + ); + const cookie = new Cookie({ + key: "a", + value: "b", + expires: lying(1), + }); + deepStrictEqual(cookie.expires?.getTime(), 1); + }); + }, +); diff --git a/packages/binden/test/headers/typed-inputs.test.ts b/packages/binden/test/headers/typed-inputs.test.ts new file mode 100644 index 00000000..f14d783c --- /dev/null +++ b/packages/binden/test/headers/typed-inputs.test.ts @@ -0,0 +1,651 @@ +import { + Accept, + AcceptCharset, + AcceptEncoding, + AcceptLanguage, + Authorization, + ContentEncoding, + ContentRange, + ContentType, + Cookie, + Forwarded, + IfModifiedSince, + Range, +} from "../../index.js"; +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** A value of any type, given in place of the one that a method declares */ +function given(value: unknown): T { + return value as T; +} + +/** A proxy that has been revoked: any operation on it throws */ +function revoked(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** The error of the test, which a getter of a proxy throws (see `throwing()`) */ +let own = new RangeError("A getter of the test"); + +/** + * A proxy whose every read throws the error of the test, a new one (whose + * stack is not formatted yet) + */ +function throwing(): object { + const error = new RangeError("A getter of the test"); + own = error; + return new Proxy( + {}, + { + get: (): never => { + throw error; + }, + }, + ); +} + +/** The conversions of the values of `counting()` */ +let conversions = 0; + +/** + * An object that counts its conversions (`toString()`, `valueOf()` and + * `Symbol.toPrimitive`): a refusal must not convert it + */ +function counting(text = "a"): object { + const convert = (): string => { + conversions += 1; + return text; + }; + return { toString: convert, valueOf: convert, [Symbol.toPrimitive]: convert }; +} + +/** + * What a call has done, while `Error.prepareStackTrace` records the errors + * whose stacks V8 formats: `returned` and its value, or the class and the + * message of its error, whether its stack was formatted once the caller got + * it, and whether it is the error of the test itself + */ +function outcome(run: () => unknown): unknown { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + return ["returned", run()]; + } catch (error: unknown) { + const { constructor, message } = error as Error; + return [ + constructor.name, + message, + formatted.has(error as Error), + error === own, + ]; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A refusal of binden: a formatted `TypeError` of the message */ +function refused(message: string): unknown { + return ["TypeError", message, true, false]; +} + +/** The parsers of the headers that take one field value only */ +const values: readonly (readonly [ + string, + (input: unknown) => unknown, + string, + unknown, +])[] = [ + [ + "Authorization", + (input): unknown => Authorization.fromString(given(input)), + "Unknown", + null, + ], + [ + "ContentEncoding", + (input): unknown => ContentEncoding.fromString(given(input)), + "unknown", + null, + ], + [ + "ContentRange", + (input): unknown => ContentRange.fromString(given(input)), + "bytes 1-0/5", + [], + ], + [ + "ContentType", + (input): unknown => ContentType.fromString(given(input)), + "text", + null, + ], + ["Cookie", (input): unknown => Cookie.fromString(given(input)), "a", []], + [ + "IfModifiedSince", + (input): unknown => IfModifiedSince.fromString(given(input)), + "yesterday", + null, + ], + [ + "Range", + (input): unknown => Range.fromString(given(input)), + "bytes=2-1", + [], + ], +]; + +/** The parsers of the headers that take their field lines too */ +const lines: readonly (readonly [ + string, + (input: unknown) => unknown, + string, + unknown, +])[] = [ + ["Accept", (input): unknown => Accept.fromString(given(input)), "text", []], + [ + "AcceptCharset", + (input): unknown => AcceptCharset.fromString(given(input)), + "utf-8;q=2", + [], + ], + [ + "AcceptEncoding", + (input): unknown => AcceptEncoding.fromString(given(input)), + "unknown", + [], + ], + [ + "AcceptLanguage", + (input): unknown => AcceptLanguage.fromString(given(input)), + "1", + [], + ], + [ + "Forwarded", + (input): unknown => Forwarded.fromString(given(input)), + "for", + [], + ], + [ + "Forwarded.parse", + (input): unknown => Forwarded.parse(given(input)), + "for", + [null], + ], +]; + +suite( + "A parser of a header refuses a value outside its declared types with a formatted TypeError, before it converts it (R27-1)", + () => { + test("of one field value: anything but undefined and a string (e.g. null, a number, an array, or an object), described", () => { + for (const [name, parse, malformed, nothing] of values) { + conversions = 0; + deepStrictEqual( + [ + outcome((): unknown => parse(null)), + outcome((): unknown => parse(5)), + outcome((): unknown => parse([malformed])), + outcome((): unknown => parse(Symbol("s"))), + outcome((): unknown => parse(counting())), + ], + [ + refused("The header is not a string: null"), + refused("The header is not a string: 5"), + refused("The header is not a string: (an object)"), + refused("The header is not a string: (a symbol)"), + refused("The header is not a string: (an object)"), + ], + name, + ); + deepStrictEqual(conversions, 0, name); + // Its declared forms: malformed text keeps its result + deepStrictEqual( + outcome((): unknown => parse(malformed)), + ["returned", nothing], + ); + deepStrictEqual( + (outcome((): unknown => parse(none)) as unknown[])[0], + "returned", + name, + ); + } + }); + + test("of field lines: anything but undefined, a string and an array of strings, each element read once, described", () => { + for (const [name, parse, malformed, nothing] of lines) { + conversions = 0; + deepStrictEqual( + [ + outcome((): unknown => parse(null)), + outcome((): unknown => parse(5)), + outcome((): unknown => parse(counting())), + outcome((): unknown => parse([malformed, counting()])), + outcome((): unknown => parse([null])), + outcome((): unknown => parse(revoked())), + outcome((): unknown => parse(throwing())), + outcome((): unknown => parse([throwing()])), + ], + [ + refused("The header is not an array: null"), + refused("The header is not an array: 5"), + refused("The header is not an array: (an object)"), + refused("A field line is not a string: (an object)"), + refused("A field line is not a string: null"), + [ + "TypeError", + "Cannot perform 'IsArray' on a proxy that has been revoked", + true, + false, + ], + refused("The header is not an array: (an object)"), + refused("A field line is not a string: (an object)"), + ], + name, + ); + deepStrictEqual(conversions, 0, name); + deepStrictEqual( + outcome((): unknown => parse(malformed)), + ["returned", nothing], + ); + deepStrictEqual( + outcome((): unknown => parse([malformed])), + ["returned", nothing], + ); + deepStrictEqual( + outcome((): unknown => parse([])), + ["returned", []], + ); + } + }); + + test("an array of field lines is read once, by its indexes (a getter that changes a line on its second read changes nothing)", () => { + let reads = 0; + const changing = new Proxy(["gzip"], { + get: (target, key, receiver): unknown => { + if (key === "0") { + reads += 1; + return reads === 1 ? "gzip" : given(counting()); + } + return Reflect.get(target, key, receiver) as unknown; + }, + }); + deepStrictEqual(AcceptEncoding.fromString(changing).map(String), [ + "gzip", + ]); + deepStrictEqual(reads, 1); + }); + + test("negotiate(): offers that are not an array, an offer that is not a string (described, not converted), and a header of another type", () => { + for (const [name, negotiate, refusal, offer] of [ + [ + "Accept", + ( + offers: readonly string[], + input?: string[] | string, + ): string | false => Accept.negotiate(offers, input), + "Invalid media type", + "text/html", + ], + [ + "AcceptCharset", + ( + offers: readonly string[], + input?: string[] | string, + ): string | false => AcceptCharset.negotiate(offers, input), + "Invalid charset", + "utf-8", + ], + [ + "AcceptEncoding", + ( + offers: readonly string[], + input?: string[] | string, + ): string | false => AcceptEncoding.negotiate(offers, input), + "Invalid content coding", + "gzip", + ], + [ + "AcceptLanguage", + ( + offers: readonly string[], + input?: string[] | string, + ): string | false => AcceptLanguage.negotiate(offers, input), + "Invalid language tag", + "en", + ], + ] as const) { + conversions = 0; + deepStrictEqual( + [ + outcome((): unknown => negotiate(given(null))), + outcome((): unknown => negotiate(given(offer))), + outcome((): unknown => negotiate(given([offer, counting(offer)]))), + outcome((): unknown => negotiate(given([5]))), + outcome((): unknown => negotiate([offer], given(null))), + outcome((): unknown => + negotiate([offer], given([counting(offer)])), + ), + outcome((): unknown => negotiate(given(throwing()))), + ], + [ + refused("`offers` is not an array: null"), + refused(`\`offers\` is not an array: ${offer}`), + refused(`${refusal}: (an object)`), + refused(`${refusal}: 5`), + refused("The header is not an array: null"), + refused("A field line is not a string: (an object)"), + refused("`offers` is not an array: (an object)"), + ], + name, + ); + deepStrictEqual(conversions, 0, name); + // Its declared forms + deepStrictEqual( + [ + negotiate([offer]), + negotiate([offer], offer), + negotiate([offer], [offer]), + negotiate([]), + ], + [offer, offer, offer, false], + name, + ); + } + }); + + test("negotiate(): the offers are read once, by their indexes", () => { + let reads = 0; + const changing = new Proxy(["text/html"], { + get: (target, key, receiver): unknown => { + if (key === "0") { + reads += 1; + return reads === 1 ? "text/html" : "text/evil"; + } + return Reflect.get(target, key, receiver) as unknown; + }, + }); + deepStrictEqual(Accept.negotiate(changing, "text/*"), "text/html"); + deepStrictEqual(reads, 1); + }); + }, +); + +/** Each class of a header, with a valid init */ +const inits: readonly (readonly [ + string, + (init: unknown) => unknown, + object, +])[] = [ + ["Accept", (init): unknown => new Accept(given(init)), { type: "text/html" }], + [ + "AcceptCharset", + (init): unknown => new AcceptCharset(given(init)), + { charset: "utf-8" }, + ], + [ + "AcceptEncoding", + (init): unknown => new AcceptEncoding(given(init)), + { encoding: "gzip" }, + ], + [ + "AcceptLanguage", + (init): unknown => new AcceptLanguage(given(init)), + { language: "en" }, + ], + [ + "Authorization", + (init): unknown => new Authorization(given(init)), + { type: "Basic" }, + ], + [ + "ContentEncoding", + (init): unknown => new ContentEncoding(given(init)), + { encoding: "gzip" }, + ], + [ + "ContentRange", + (init): unknown => new ContentRange(given(init)), + { start: 0, end: 1, size: 2 }, + ], + [ + "ContentType", + (init): unknown => new ContentType(given(init)), + { type: "text/plain" }, + ], + [ + "Cookie", + (init): unknown => new Cookie(given(init)), + { key: "a", value: "b" }, + ], + [ + "Forwarded", + (init): unknown => new Forwarded(given(init)), + { for: "unknown" }, + ], + [ + "IfModifiedSince", + (init): unknown => new IfModifiedSince(given(init)), + { date: new Date(0) }, + ], + ["Range", (init): unknown => new Range(given(init)), { start: 0 }], +]; + +suite( + "A header constructed from an init that is not an object, or that throws once read, throws a formatted error (R27-1)", + () => { + test("anything but an object (e.g. null, undefined, a string, whose properties destructuring would read, or a function) is refused, described", () => { + for (const [name, create, init] of inits) { + deepStrictEqual( + [ + outcome((): unknown => create(null)), + outcome((): unknown => create(none)), + outcome((): unknown => create("abc")), + outcome((): unknown => create(Symbol("s"))), + outcome((): unknown => create((): object => init)), + ], + [ + refused("`init` is not an object: null"), + refused("`init` is not an object: undefined"), + refused("`init` is not an object: abc"), + refused("`init` is not an object: (a symbol)"), + refused("`init` is not an object: (a function)"), + ], + name, + ); + deepStrictEqual( + (outcome((): unknown => create(init)) as unknown[])[0], + "returned", + name, + ); + } + }); + + test("an init that a revoked proxy is, or whose getter throws, throws the error of V8 (or of the getter itself), formatted", () => { + for (const [name, create] of inits) { + deepStrictEqual( + [ + outcome((): unknown => create(revoked())), + outcome((): unknown => create(throwing())), + ], + [ + [ + "TypeError", + "Cannot perform 'get' on a proxy that has been revoked", + true, + false, + ], + ["RangeError", "A getter of the test", true, true], + ], + name, + ); + } + }); + + test("parameters of Accept and ContentType that are not an object are refused, described (a string would add a parameter of each character)", () => { + for (const parameters of ["ab", null, 5]) { + deepStrictEqual( + [ + outcome( + (): unknown => + new Accept({ + type: "text/html", + parameters: given(parameters), + }), + ), + outcome( + (): unknown => + new ContentType({ + type: "text/plain", + parameters: given(parameters), + }), + ), + ], + [ + refused(`\`parameters\` is not an object: ${String(parameters)}`), + refused(`\`parameters\` is not an object: ${String(parameters)}`), + ], + ); + } + }); + + test("a charset of ContentType that is neither a string nor null is refused, described without converting it, for every media type (R28)", () => { + conversions = 0; + for (const type of ["text/plain", "multipart/form-data"]) { + const create = + (charset: unknown): (() => unknown) => + (): unknown => + new ContentType({ type, boundary: "b", charset: given(charset) }); + deepStrictEqual( + [ + outcome(create(Symbol("s"))), + outcome(create(5)), + outcome(create(counting("utf-8"))), + outcome(create(revoked())), + ], + [ + refused("`charset` is not a string: (a symbol)"), + refused("`charset` is not a string: 5"), + refused("`charset` is not a string: (an object)"), + refused("`charset` is not a string: (an object)"), + ], + type, + ); + } + deepStrictEqual(conversions, 0); + // `null`, `undefined` and an empty string are no charset + deepStrictEqual( + [null, none, "", "utf-8"].map((charset) => + String( + new ContentType({ type: "text/plain", charset: given(charset) }), + ), + ), + ["text/plain", "text/plain", "text/plain", "text/plain; charset=utf-8"], + ); + }); + + test("an unknown coding or scheme is described, never converted", () => { + conversions = 0; + deepStrictEqual( + [ + outcome( + (): unknown => new AcceptEncoding({ encoding: given(counting()) }), + ), + outcome( + (): unknown => new ContentEncoding({ encoding: given(counting()) }), + ), + outcome( + (): unknown => new Authorization({ type: given(counting()) }), + ), + outcome( + (): unknown => + new ContentEncoding({ encoding: given(Symbol("s")) }), + ), + ], + [ + refused("Unknown content coding: (an object)"), + refused("Unknown content coding: (an object)"), + refused("Unsupported authentication scheme: (an object)"), + refused("Unknown content coding: (a symbol)"), + ], + ); + deepStrictEqual(conversions, 0); + }); + + test("the date of IfModifiedSince: a Date by its time (never its methods), a number or a string; anything else refused, described", () => { + conversions = 0; + class Shifted extends Date { + // eslint-disable-next-line class-methods-use-this -- another time + public override getTime(): number { + return 0; + } + } + const date = new Date("2020-01-01T00:00:00Z"); + deepStrictEqual( + [ + String(new IfModifiedSince({ date: new Shifted(date.getTime()) })), + String(new IfModifiedSince({ date: given(date.getTime()) })), + String(new IfModifiedSince({ date: given(date.toUTCString()) })), + outcome((): unknown => new IfModifiedSince({ date: given(null) })), + outcome( + (): unknown => new IfModifiedSince({ date: given(counting()) }), + ), + outcome( + (): unknown => + new IfModifiedSince({ date: given(new Proxy(date, {})) }), + ), + outcome( + (): unknown => new IfModifiedSince({ date: given("tomorrow") }), + ), + ], + [ + date.toUTCString(), + date.toUTCString(), + date.toUTCString(), + refused("The date is not a Date: null"), + refused("The date is not a Date: (an object)"), + refused("The date is not a Date: (an object)"), + refused("Invalid date"), + ], + ); + deepStrictEqual(conversions, 0); + }); + + test("the Expires of a Cookie: a Date by its time, a proxy of a Date refused", () => { + const date = new Date("2020-01-01T00:00:00Z"); + class Shifted extends Date { + // eslint-disable-next-line class-methods-use-this -- another time + public override getTime(): number { + return 0; + } + } + deepStrictEqual( + [ + new Cookie({ + key: "a", + value: "b", + expires: new Shifted(date.getTime()), + }).expires?.toISOString(), + outcome( + (): unknown => + new Cookie({ + key: "a", + value: "b", + expires: new Proxy(date, {}), + }), + ), + ], + [date.toISOString(), refused("The Expires is not a Date")], + ); + }); + }, +); diff --git a/packages/binden/test/hearing-sends.test.ts b/packages/binden/test/hearing-sends.test.ts new file mode 100644 index 00000000..42d8801c --- /dev/null +++ b/packages/binden/test/hearing-sends.test.ts @@ -0,0 +1,253 @@ +import { Binden, type Context } from "../index.js"; +import { capture, ioTurn, listen, send, within } from "./helpers.js"; +import { emitterMethods, guard } from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** How a send has settled: resolved, or the message of its error */ +function outcomeOf(sent: Promise): Promise { + return sent.then( + () => "resolved", + (error: unknown) => `rejected:${(error as Error).message}`, + ); +} + +/** + * A `Readable` that never ends (`body`: pushed once read, then its end), + * whose destroy never completes: it never closes, so that a guard stays on + * it, and a later error of it is heard by the listeners that it still has + */ +function lasting(body: string | null = null): Readable { + return new Readable({ + autoDestroy: false, + read(): void { + if (body !== null) { + this.push(body); + this.push(null); + } + }, + destroy(): void { + // Never completes + }, + }); +} + +/** The messages of the late errors that have been logged */ +function late(records: Record[]): unknown[] { + return records + .filter(({ msg }) => msg === "A released stream has failed") + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +suite("The listener of `error` of a send of a Node.js `Readable`", () => { + test("counts as hearing it: an error that fails the send is not reported by a guard that listens before (e.g. of another response that has released it), and a later one is, once the send has settled", async () => { + const source = lasting(); + const reports: unknown[] = []; + // The guard of another release, whose listener comes first + guard(source, emitterMethods, (error: unknown) => { + reports.push((error as Error).message); + }); + const captured = capture(); + const outcomes: string[] = []; + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + const sent = context.send(source); + setImmediate(() => { + source.emit("error", new Error("Heard by the send")); + }); + outcomes.push(await outcomeOf(sent)); + throw new Error("Replied"); + }, + ), + ); + try { + const { status } = await send(listening.port); + // Once the send has settled: the guard reports it, once + source.emit("error", new Error("Later")); + await ioTurn(); + deepStrictEqual( + [status, outcomes, reports, late(captured.records)], + [500, ["rejected:Heard by the send"], ["Later"], []], + ); + } finally { + await listening.close(); + } + }); + + test("an error that fails the send has decided it: a guard that listens after the send does not report it either", async () => { + const source = lasting(); + const reports: unknown[] = []; + const captured = capture(); + const outcomes: string[] = []; + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + const sent = context.send(source); + // The guard of another release, whose listener comes after the + // one of the send: the send settles (and no longer hears the + // source) before the guard gets the error + guard(source, emitterMethods, (error: unknown) => { + reports.push((error as Error).message); + }); + source.emit("error", new Error("Decided")); + outcomes.push(await outcomeOf(sent)); + throw new Error("Replied"); + }, + ), + ); + try { + const { status } = await send(listening.port); + await ioTurn(); + deepStrictEqual( + [status, outcomes, reports, late(captured.records)], + [500, ["rejected:Decided"], [], []], + ); + } finally { + await listening.close(); + } + }); + + test("of a send stopped by another writer reports an error that the source emits once, although a guard listens before", async () => { + const source = lasting(); + const reports: unknown[] = []; + guard(source, emitterMethods, (error: unknown) => { + reports.push((error as Error).message); + }); + const captured = capture(); + const outcomes: string[] = []; + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + const sent = context.send(source); + context.response.end("ended"); + // Heard by the send, which no longer fails with it: reported by it + source.emit("error", new Error("Once the response has ended")); + outcomes.push(await outcomeOf(sent)); + }, + ), + ); + try { + const { status, body } = await send(listening.port); + await ioTurn(); + deepStrictEqual( + [status, body, outcomes, reports, late(captured.records)], + [200, "ended", ["resolved"], [], ["Once the response has ended"]], + ); + } finally { + await listening.close(); + } + }); + + test("no longer hears it once its removal has thrown: a later error is reported by its guard", async () => { + const source = lasting("ok"); + // Removes the listener of `error`, then throws + const remove = source.removeListener.bind(source); + Object.assign(source, { + removeListener(event: string, listener: () => void): Readable { + remove(event, listener); + if (event === "error") { + throw new Error("No removal of error"); + } + return source; + }, + }); + const captured = capture(); + const outcomes: string[] = []; + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + outcomes.push(await outcomeOf(context.send(source))); + }, + ), + ); + try { + const { status, body } = await send(listening.port); + await within( + new Promise((resolve) => { + setImmediate(resolve); + }), + ); + source.emit("error", new Error("After its removal")); + await ioTurn(); + deepStrictEqual( + [ + status, + body, + outcomes, + source.listenerCount("error"), + late(captured.records), + captured.records + .filter( + ({ msg }) => + msg === "The removal of a listener of a stream has failed", + ) + .map(({ failure }) => (failure as { message?: unknown }).message), + ], + [ + 200, + "ok", + ["resolved"], + 1, + ["After its removal"], + ["No removal of error"], + ], + ); + } finally { + await listening.close(); + } + }); +}); + +suite("A Node.js `Readable` sent to two responses", () => { + test("whose release by one fails (its `_destroy()`) rejects the send of the other that hears it, and is not logged as a late error", async () => { + const failure = new Error("The shared source has failed"); + const shared = new Readable({ + read(): void { + // Never read + }, + destroy(_error, callback): void { + process.nextTick(callback, failure); + }, + }); + const { promise: listened, resolve: listens } = + Promise.withResolvers(); + const captured = capture(); + const outcomes = new Map(); + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + const method = String(context.request.method); + if (method === "GET") { + const sent = context.send(shared); + listens(null); + outcomes.set(method, await outcomeOf(sent)); + throw new Error("Replied"); + } + // Released to `HEAD` (destroyed under its guard), once the send of + // `GET` listens + await listened; + outcomes.set(method, await outcomeOf(context.send(shared))); + }, + ), + ); + try { + const statuses = await Promise.all([ + send(listening.port).then(({ status }) => status), + send(listening.port, { method: "HEAD" }).then(({ status }) => status), + ]); + await ioTurn(); + deepStrictEqual( + [statuses, Object.fromEntries(outcomes), late(captured.records)], + [ + [500, 200], + { GET: `rejected:${failure.message}`, HEAD: "resolved" }, + [], + ], + ); + } finally { + await listening.close(); + } + }); +}); diff --git a/packages/binden/test/helpers.ts b/packages/binden/test/helpers.ts new file mode 100644 index 00000000..7886ba39 --- /dev/null +++ b/packages/binden/test/helpers.ts @@ -0,0 +1,241 @@ +import { + type IncomingHttpHeaders, + type OutgoingHttpHeaders, + type Server, + request as httpRequest, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; +import { serializers } from "../index.js"; + +/** + * A caught value as the reason of a rejection: the `Error` itself (assertions + * throw one), or an `Error` of its text + */ +export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +/** 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. The + * default only guards against a hang, with a deadline that a loaded machine + * does not reach: a test that asserts a timing passes its own + */ +export async function within(promise: Promise, ms = 10_000): 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); + } +} + +/** + * Resolves after a turn of the pending I/O (a 5 ms timer, then two turns of + * `setImmediate()`): once a stall of the process (e.g. a loaded machine) has + * passed, the timers run before the I/O that has come meanwhile is read, the + * system may not have delivered it yet (e.g. the end of a loopback + * connection, which macOS delivers asynchronously), and the `close` of a + * socket whose end is read then is emitted only once the first turn has run + * (in the close callbacks of the event loop), so that silence (or an expired + * deadline) is judged only after all three + */ +export async function ioTurn(): Promise { + await sleep(5); + for (let turn = 0; turn < 2; turn += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); + } +} + +/** + * Resolves once `check()` holds (polled every 5 ms), or rejects once it has + * not within `ms` milliseconds, judged after a turn of the pending I/O (see + * `ioTurn()`): a poller that ends by itself, whoever waits for it (e.g. a + * `within()` with a shorter deadline), so that a failure never keeps the + * process of a test alive + */ +export async function pollUntil( + check: () => boolean, + ms = 10_000, +): Promise { + const deadline = Date.now() + ms; + while (!check()) { + if (Date.now() > deadline) { + await ioTurn(); + if (check()) { + return; + } + throw new Error(`Not true within ${ms} ms: ${String(check)}`); + } + await sleep(5); + } +} + +/** 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 (within `ms`, + * 10 s by default) + */ + 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 = 10_000, + ): Promise> => { + const find = (): Record | undefined => + records().find(({ msg }) => msg === message); + await pollUntil(() => typeof find() !== "undefined", ms).catch(() => { + throw new Error(`No record "${message}": ${lines.join("")}`); + }); + return find() as Record; + }, + }; +} + +/** A listening server of an application */ +export interface IListening { + readonly server: Server; + readonly port: number; + readonly url: string; + close(): Promise; +} + +/** An application, whatever its state */ +export interface IApp { + createServer(): Server; +} + +/** + * The address of the test servers, which their clients connect to: never `::` + * (as `listen(0)` without a host binds), whose port may be one that a foreign + * process listens on at 127.0.0.1 (its connections would reach that process) + */ +export const HOST = "127.0.0.1"; + +/** Creates the server of the application and listens on a free port of `HOST` */ +export async function listen(app: IApp): Promise { + const server: Server = app.createServer(); + server.listen(0, HOST); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + server, + port, + url: `http://${HOST}:${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 to `HOST`) and reads the response within + * `ms` (10 s by default: a guard against a hang) + */ +export function send( + port: number, + { + path = "/", + method = "GET", + headers = {}, + body = null, + ms = 10_000, + }: { + path?: string; + method?: string; + headers?: OutgoingHttpHeaders; + body?: string | null; + ms?: number; + } = {}, +): Promise { + return new Promise((resolve, reject) => { + const sent = httpRequest( + { + agent: false, + headers, + host: HOST, + 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/http.ts b/packages/binden/test/http.ts new file mode 100644 index 00000000..43dfc3bb --- /dev/null +++ b/packages/binden/test/http.ts @@ -0,0 +1,120 @@ +import { BindenRequest, BindenResponse } from "../index.js"; +import { + type IncomingHttpHeaders, + IncomingMessage, + type OutgoingHttpHeaders, + type Server, + createServer, + request as httpRequest, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { HOST } from "./helpers.js"; +import { once } from "node:events"; + +/** 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 `HOST` (127.0.0.1) */ +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, HOST); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const url = `http://${HOST}:${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(10_000), + }); + 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/integer-status.test.ts b/packages/binden/test/integer-status.test.ts new file mode 100644 index 00000000..de42f7af --- /dev/null +++ b/packages/binden/test/integer-status.test.ts @@ -0,0 +1,364 @@ +import { Binden, BindenResponse, type Context } from "../index.js"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; +import { converse } from "./connection.js"; +import { join } from "node:path"; +import { listen } from "./helpers.js"; + +/** A file that `sendFile()` sends */ +const file = join(import.meta.dirname, "..", "package.json"); + +/** The calls that reply with a body (`hello`) by the status set before */ +const replies: Record unknown> = { + "send() of a string": async (response) => response.send("hello"), + "send() of a Readable": async (response) => + response.send(Readable.from(["hello"])), + "json()": async (response) => response.json("hello"), + "sendFile()": async (response) => response.sendFile(file), + "end() of a chunk": (response) => response.end("hello"), + "write()": (response) => { + response.write("hello"); + response.end(); + }, +}; + +/** + * Replies a request by a call with a status, then the pipelined request + * that follows it with `next`: what the client receives (without the `Date` + * of each response) until the connection closes + */ +async function received(name: string, status: number): Promise { + const reply = replies[name]; + const app = new Binden().use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.send("next"); + return; + } + context.response.statusCode = status; + await reply(context.response); + }); + const listening = await listen(app); + try { + const { received: text } = await converse( + listening.port, + "GET / HTTP/1.1\r\nHost: localhost\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + return text.replaceAll(/Date: [^\r]*\r\n/gu, ""); + } finally { + await listening.close(); + } +} + +/** + * A response of binden, or of plain Node.js, to an HTTP/1.1 request (`GET` by + * default) without a connection + */ +function responseOf(binden: boolean, method = "GET"): ServerResponse { + const request = new IncomingMessage(new Socket()); + request.httpVersionMajor = 1; + request.httpVersionMinor = 1; + request.method = method; + return binden ? new BindenResponse(request) : new ServerResponse(request); +} + +/** + * Statuses that are not integers, by name, which convert to `201`: an object + * (that counts its conversions), a string and numbers + */ +const unconverted: Record void) => unknown> = { + object: (count) => ({ + [Symbol.toPrimitive](): number { + count(); + return 201; + }, + }), + string: () => "201", + fractional: () => 201.5, + wrapped: () => 2 ** 32 + 201, +}; + +/** The calls that commit the head by the status of the response, by name */ +const commits: Record unknown> = { + "write()": (response) => response.write("abc"), + 'write("")': (response) => response.write(""), + "end()": (response) => response.end(), + 'end("")': (response) => response.end(""), + "end(callback)": (response) => + response.end(() => { + // Never called by a refused head + }), + "end(null)": (response) => response.end(null), + "end() of an empty Buffer": (response) => response.end(Buffer.alloc(0)), + "end() of a chunk": (response) => response.end("abc"), + "flushHeaders()": (response) => { + response.flushHeaders(); + }, + "writeHead() of the status of the response": (response) => + response.writeHead(response.statusCode), +}; + +/** The code and the message of what a call throws (`null` when it does not) */ +function thrownBy(run: () => unknown): unknown { + try { + run(); + return null; + } catch (error: unknown) { + const { code, message } = error as { code?: unknown; message: string }; + return [code, message]; + } +} + +suite( + "A statusCode that is a number but not an integer is the integer that Node.js commits (H29-2)", + () => { + for (const name of Object.keys(replies)) { + test(`${name}: framed as that integer, and the pipelined request that follows is answered`, async () => { + for (const [given, integer] of [ + [204.5, 204], + [205.5, 205], + [304.5, 304], + [200.5, 200], + // As `| 0` converts it + [2 ** 32 + 204, 204], + ] as const) { + const expected = await received(name, integer); + ok(expected.endsWith("\r\n\r\nnext"), `${name} ${String(integer)}`); + deepStrictEqual( + await received(name, given), + expected, + `${name} ${String(given)}`, + ); + } + }); + } + + test("one out of range, also once truncated, stays as it is: Node.js refuses it by the value given, as plain Node.js", () => { + const calls: Record unknown> = { + "end()": (response) => response.end("hello"), + "write()": (response) => response.write("hello"), + "flushHeaders()": (response) => { + response.flushHeaders(); + }, + }; + for (const [name, call] of Object.entries(calls)) { + for (const status of [99.5, 1000.5, 1e10, 2 ** 32 + 99, Number.NaN]) { + const [ours, plain] = [true, false].map((binden) => { + const response = responseOf(binden); + response.statusCode = status; + return [ + thrownBy(() => call(response)), + response.statusCode, + response.headersSent, + ]; + }); + deepStrictEqual(ours, plain, `${name} ${String(status)}`); + } + } + }); + }, +); + +suite( + "A head refused by its reason phrase stores the status, converted once, as plain Node.js: another attempt does not convert it again (H29-1)", + () => { + test("flushHeaders(), a write() and an end(), whose head Node.js commits by the status of the response", () => { + const calls: Record unknown> = { + "flushHeaders()": (response) => { + response.flushHeaders(); + }, + "write()": (response) => response.write("abc"), + "end()": (response) => response.end("abc"), + }; + for (const [name, call] of Object.entries(calls)) { + const [ours, plain] = [true, false].map((binden) => { + let conversions = 0; + const response = responseOf(binden); + response.statusCode = { + [Symbol.toPrimitive](): number { + conversions += 1; + return 201; + }, + } as never; + response.statusMessage = "bad\r\nx: y"; + const first = thrownBy(() => call(response)); + const status: unknown = response.statusCode; + response.statusMessage = "Created"; + const retry = thrownBy(() => call(response)); + return [first, status, retry, response.headersSent, conversions]; + }); + deepStrictEqual(ours, plain, name); + deepStrictEqual( + ours, + [ + ["ERR_INVALID_CHAR", "Invalid character in statusMessage"], + 201, + null, + true, + 1, + ], + name, + ); + } + }); + + test("every call that commits the head by it (also an end() without a chunk, and any write of a response to HEAD) stores the integer, also of a string or a fractional number (C30-3, H30-1)", () => { + for (const method of ["GET", "HEAD"]) { + for (const [name, call] of Object.entries(commits)) { + for (const [kind, status] of Object.entries(unconverted)) { + const [ours, plain] = [true, false].map((binden) => { + let conversions = 0; + const response = responseOf(binden, method); + response.statusCode = status(() => { + conversions += 1; + }) as never; + response.statusMessage = "bad\r\nx: y"; + const first = thrownBy(() => call(response)); + const stored: unknown = response.statusCode; + response.statusMessage = "Created"; + const retry = thrownBy(() => call(response)); + return [first, stored, retry, response.headersSent, conversions]; + }); + const label = `${method} ${name} ${kind}`; + deepStrictEqual(ours, plain, label); + deepStrictEqual( + ours, + [ + ["ERR_INVALID_CHAR", "Invalid character in statusMessage"], + 201, + null, + true, + kind === "object" ? 1 : 0, + ], + label, + ); + } + } + } + }); + + test("a number out of range once truncated is refused by the value given before the encoding of a chunk is checked, as plain Node.js (H30-3)", () => { + const calls: Record unknown> = { + "write()": (response) => response.write("abc", "bogus" as never), + "end()": (response) => response.end("abc", "bogus" as never), + }; + for (const method of ["GET", "HEAD"]) { + for (const [name, call] of Object.entries(calls)) { + for (const status of [ + 1000, + 1000.5, + 99.9, + -204, + 2 ** 32 + 99, + Number.NaN, + "1000", + ]) { + const [ours, plain] = [true, false].map((binden) => { + const response = responseOf(binden, method); + response.statusCode = status as never; + return [ + thrownBy(() => call(response)), + response.statusCode, + response.headersSent, + ]; + }); + const label = `${method} ${name} ${String(status)}`; + deepStrictEqual(ours, plain, label); + deepStrictEqual( + (ours[0] as unknown[])[0], + "ERR_HTTP_INVALID_STATUS_CODE", + label, + ); + } + } + } + }); + + test("a write() or an end() of a chunk that Node.js ignores (of a destroyed response, or once it has ended without its head) neither converts nor refuses the status, as plain Node.js; an end() without a chunk of a destroyed response does", () => { + const calls: Record unknown> = { + "write()": (response) => { + response.destroy(); + return response.write("abc", () => { + // Called back with the error of the destroyed response + }); + }, + "end() of a chunk": (response) => { + response.destroy(); + return response.end("abc"); + }, + "end() once ended without a head": (response) => { + response.destroy(); + response.end("abc"); + return response.end(); + }, + "end()": (response) => { + response.destroy(); + return response.end(); + }, + // Committed: the status is stored before the reason is refused + "end(callback) refused by its reason phrase": (response) => { + response.destroy(); + response.statusMessage = "bad\r\nx: y"; + return response.end(() => { + // Never called by a refused head + }); + }, + }; + for (const [name, call] of Object.entries(calls)) { + for (const kind of ["object", "1000"]) { + const [ours, plain] = [true, false].map((binden) => { + let conversions = 0; + const response = responseOf(binden); + response.statusCode = + kind === "object" + ? (unconverted.object(() => { + conversions += 1; + }) as never) + : 1000; + const thrown = thrownBy(() => call(response)); + return [ + thrown, + typeof response.statusCode, + response.headersSent, + conversions, + ]; + }); + deepStrictEqual(ours, plain, `${name} ${kind}`); + deepStrictEqual( + ours[3], + kind === "object" && + ["end()", "end(callback) refused by its reason phrase"].includes( + name, + ) + ? 1 + : 0, + `${name} ${kind}`, + ); + } + } + }); + + test("a status given to writeHead() is converted by the call alone: a refused call leaves the status of the response as it was", () => { + let conversions = 0; + const response = responseOf(true); + const status = { + [Symbol.toPrimitive](): number { + conversions += 1; + return 201; + }, + }; + deepStrictEqual( + thrownBy(() => response.writeHead(status as never, "bad\r\nx: y")), + ["ERR_INVALID_CHAR", "Invalid character in statusMessage"], + ); + deepStrictEqual([response.statusCode, conversions], [200, 1]); + response.writeHead(status as never, "Created"); + deepStrictEqual( + [response.statusCode, response.headersSent, conversions], + [201, true, 2], + ); + }); + }, +); diff --git a/packages/binden/test/keep-alive.test.ts b/packages/binden/test/keep-alive.test.ts new file mode 100644 index 00000000..cc5fb956 --- /dev/null +++ b/packages/binden/test/keep-alive.test.ts @@ -0,0 +1,185 @@ +/* eslint-disable init-declarations */ +import { Agent, type Server, request } from "node:http"; +import { Binden, BindenError, type Context } from "../index.js"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, rejects } from "node:assert"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +/** The status, the body and whether the connection was reused */ +interface IExchange { + status: number; + body: string; + reused: boolean; +} + +/** + * As a compression: the `Content-Length` is removed when the headers are + * committed, and the body is chunked + */ +function dropLength({ response }: Context): void { + const writeHead = response.writeHead.bind(response); + response.writeHead = (code: number): typeof response => { + response.removeHeader("Content-Length"); + return writeHead(code); + }; +} + +// 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"), + "/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"])), + "/chunked": (context) => { + dropLength(context); + return context.send("chunked"); + }, + "/chunked-error": (context) => { + dropLength(context); + throw new BindenError(400, { expose: true, message: "error" }); + }, + }; + + 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, + host: "127.0.0.1", + port, + signal: AbortSignal.timeout(10_000), + }, + (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 }); + }) + .once("error", reject); + }, + ); + 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, "127.0.0.1"); + 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], + ]; + + 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 whose length is removed at its commit is chunked, then another request", async () => { + for (const [path, status, body] of [ + ["/chunked", 200, "chunked"], + ["/chunked-error", 400, "error"], + ] as const) { + deepStrictEqual(await exchange(path), { status, body, reused: true }); + // Not ended by closing the connection + 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" }); + 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), { + status, + body: "", + reused: true, + }); + deepStrictEqual(await exchange("/ok"), { + status: 200, + body: "ok", + reused: true, + }); + }); + } +}); diff --git a/packages/binden/test/kept-destroy-errors.test.ts b/packages/binden/test/kept-destroy-errors.test.ts new file mode 100644 index 00000000..02e867ed --- /dev/null +++ b/packages/binden/test/kept-destroy-errors.test.ts @@ -0,0 +1,106 @@ +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs the fixture of a source that keeps its destroy error in a process of + * its own, whose collections the test controls (`--expose-gc`) + */ +function kept(how: string): { + status: number | null; + stderr: string; + result: Record; +} { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "kept-destroy-errors.ts"), + how, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { + status, + stderr, + result: JSON.parse(stdout === "" ? "{}" : stdout) as Record< + string, + unknown + >, + }; +} + +/** A stream-like source of its own, which records the arguments of each destroy */ +class Source extends EventEmitter { + public readonly destroys: unknown[][] = []; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(...args: unknown[]): this { + this.destroys.push(args); + return this; + } +} + +suite( + "A stream-like source that keeps the error that it is destroyed with (as minipass 7 keeps the error that it emits), which the application keeps", + () => { + for (const [how, when] of [ + ["gone", "once the client has gone away"], + ["deadline", "at the deadline"], + ["writer", "once another writer has ended the response"], + ] as const) { + test(`released ${when}: keeps neither the request nor the response (R2 R21-H1)`, () => { + const { status, stderr, result } = kept(how); + const { alive, settled, sources } = result; + deepStrictEqual( + { status, stderr, alive, settled, sources }, + { status: 0, stderr: "", alive: 0, settled: 30, sources: 30 }, + ); + if (how !== "writer") { + // Destroyed with the reason of the cancellation, which it keeps + deepStrictEqual(result.kept, 30); + } + }); + } + }, +); + +suite( + "A readable of a stream-like source released with a reason whose stack cannot be read (a getter of its own that throws)", + () => { + test("destroys the source with that reason as it is, and calls back without an error", () => { + const reason = new Error("Cancelled"); + Object.defineProperty(reason, "stack", { + get(): never { + throw new Error("No stack"); + }, + }); + const source = new Source(); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => reason, + () => null, + ).on("error", () => null); + readable.destroy(); + deepStrictEqual([readable.errored, source.destroys], [null, [[reason]]]); + }); + }, +); diff --git a/packages/binden/test/kept-guards.test.ts b/packages/binden/test/kept-guards.test.ts new file mode 100644 index 00000000..fe89ee07 --- /dev/null +++ b/packages/binden/test/kept-guards.test.ts @@ -0,0 +1,112 @@ +import { emitterMethods, guard } from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; +import { ioTurn } from "./helpers.js"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, without a handler + * of the process, with these flags of Node.js: a crash (e.g. an unhandled + * `error` event) is its exit code and output, not a failure of the test + * runner + */ +function fixture(name: string, ...flags: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + ...flags, + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 40_000 }, + ); + return { status, stdout, stderr }; +} + +/** The listeners of `error` and `close` of a source */ +function counts(source: EventEmitter): number[] { + return [source.listenerCount("error"), source.listenerCount("close")]; +} + +suite( + "The guard of a source whose state cannot tell that it has closed", + () => { + test("stays once it has closed (also installed once closed), and reports each later error once; it leaves on the turn after a close that its state tells", async () => { + const reported: unknown[] = []; + const report = (error: unknown): void => { + reported.push((error as Error).message); + }; + // An emitter, without the state of a stream + const source = new EventEmitter(); + guard(source, emitterMethods, report); + source.emit("close"); + await ioTurn(); + const later = new Error("After its close"); + source.emit("error", later); + // The same error once more (e.g. of a second destroy): not again + source.emit("error", later); + source.emit("error", new Error("Another")); + // A second close does not remove it either + source.emit("close"); + await ioTurn(); + const kept = counts(source); + // Installed by a send that has seen its close (`closed`) + const closed = new EventEmitter(); + guard(closed, emitterMethods, report, true); + await ioTurn(); + const installed = counts(closed); + // Its state tells it now + Object.assign(source, { _readableState: { closeEmitted: true } }); + source.emit("close"); + deepStrictEqual(counts(source), [1, 1]); + await ioTurn(); + deepStrictEqual( + [kept, installed, counts(source), reported], + [ + [1, 1], + [1, 1], + [0, 0], + ["After its close", "Another"], + ], + ); + }); + + test("a later error of a source of readable-stream 2 or 3, through2 4, or an emitter of its own, once binden has cancelled, released or failed it, is logged once and does not crash the process", () => { + // Without a listener of the application: a feed written by a timer of the + // application once cancelled (by the deadline, or the client gone away), + // an emitter that emits an error after its close, and sources that + // refuse a listener of the send, then are destroyed with an error + const lines = [ + // Committed by its first event: cut at the deadline + "/through2-deadline 200", + "/through2-gone 200", + "/v2-gone 200", + "/v3-gone 200", + "/custom-0 500", + "/custom-20 500", + "/refused-end 500", + "/refused-data 500", + ].map((line) => `${line} later=true each logged once guard=1/1\n`); + deepStrictEqual(fixture("kept-guards.ts"), { + status: 0, + stdout: lines.join(""), + stderr: "", + }); + }); + + test("holds nothing but the report of a late error: the responses are collected", () => { + // 200 `PassThrough`s of readable-stream 3 and 200 emitters whose destroy + // emits `close`, cancelled at the deadline: each closed, without a + // listener of its send, with its guard + deepStrictEqual(fixture("kept-guard-retention.ts", "--expose-gc"), { + status: 0, + stdout: "collected 400 0 0 400 400\n", + stderr: "", + }); + }); + }, +); diff --git a/packages/binden/test/late-reports.test.ts b/packages/binden/test/late-reports.test.ts new file mode 100644 index 00000000..ba25c06a --- /dev/null +++ b/packages/binden/test/late-reports.test.ts @@ -0,0 +1,279 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { type ICaptured, capture, ioTurn, listen, within } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { + decided, + emitterMethods, + guard, + readStreamLike, + reportLate, + streamLikeOf, +} from "../src/stream-like.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; + +/** The message of each late error of a source logged so far */ +function reported(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +/** A `Readable` that is never read (its data is pushed by the test) */ +function readable(): Readable { + return new Readable({ + read(): void { + // Pushed by the test, if at all + }, + }); +} + +/** + * Runs `scenario` as the middleware of `/` (`/next` replies `next`), with + * `/` and `/next` pipelined on one connection: what the client receives, how + * the scenario has settled, and the logger, once the reply of `/next` has + * come and the pending I/O has run (see `ioTurn()`) + */ +async function run( + scenario: (context: Context) => Promise, +): Promise<[string, unknown, ICaptured]> { + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + } else { + outcome.resolve(await scenario(context)); + } + }); + const listening = await listen(app); + try { + const { received } = await converse( + listening.port, + `${get("/")}${get("/next")}`, + { complete: (text) => text.endsWith("\r\n\r\nnext") }, + ); + const settled = await within(outcome.promise); + await ioTurn(); + return [received.replaceAll(/\r\nDate: [^\r]*/gu, ""), settled, captured]; + } finally { + await listening.close(); + } +} + +/** How a send has settled: `resolved`, or the message of what it has rejected with */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => (error as Error).message, + ); +} + +suite("A late error of a source is reported once", () => { + test("by its guard, then by its send (reportLate()): once", async () => { + const reports: unknown[] = []; + const report = (error: unknown): void => { + reports.push(error); + }; + const source = new EventEmitter(); + const failure = new Error("The source has failed"); + guard(source, emitterMethods, report); + source.emit("error", failure); + reportLate(source, failure, report); + // Reported on a later turn + await ioTurn(); + deepStrictEqual(reports, [failure]); + }); + + test("by its send (reportLate()), then by its guard once the source emits it: once", async () => { + const reports: unknown[] = []; + const report = (error: unknown): void => { + reports.push(error); + }; + const source = new EventEmitter(); + const failure = new Error("The source has failed"); + guard(source, emitterMethods, report); + reportLate(source, failure, report); + source.emit("error", failure); + // Again (e.g. a source that emits its error twice) + source.emit("error", failure); + await ioTurn(); + // Once more, once it has been reported + source.emit("error", failure); + await ioTurn(); + deepStrictEqual(reports, [failure]); + }); + + test("never once it has decided a send, by its send nor by its guard", async () => { + const reports: unknown[] = []; + const report = (error: unknown): void => { + reports.push(error); + }; + const source = new EventEmitter(); + const failure = new Error("The body is too long"); + decided(failure); + guard(source, emitterMethods, report); + reportLate(source, failure, report); + source.emit("error", failure); + await ioTurn(); + deepStrictEqual(reports, []); + }); + + test("once for each source that has failed with it", async () => { + const reports: unknown[] = []; + const sources = [new EventEmitter(), new EventEmitter()]; + const failure = new Error("Shared"); + for (const source of sources) { + guard(source, emitterMethods, (error) => { + reports.push([source, error]); + }); + source.emit("error", failure); + reportLate(source, failure, (error) => { + reports.push([source, error]); + }); + } + await ioTurn(); + deepStrictEqual(reports, [ + [sources[0], failure], + [sources[1], failure], + ]); + }); + + test("a value that is not an object (which cannot be recorded) is reported each time", async () => { + const reports: unknown[] = []; + const report = (error: unknown): void => { + reports.push(error); + }; + const source = new EventEmitter(); + guard(source, emitterMethods, report); + reportLate(source, "failed", report); + source.emit("error", "failed"); + await ioTurn(); + deepStrictEqual(reports, ["failed", "failed"]); + }); + + test("by a stream-like source read once it has ended, which emits it again once its listeners are removed: once", async () => { + const reports: unknown[] = []; + const report = (error: unknown): void => { + reports.push(error); + }; + const source = Object.assign(new EventEmitter(), { + pause(): void { + // Never full + }, + resume(): void { + // Emits by the test + }, + }); + const methods = streamLikeOf(source); + ok(methods !== null); + const read = readStreamLike( + source, + methods, + () => new Error("Cancelled"), + report, + ); + read.resume(); + const failure = new Error("Failed after its end"); + source.emit("end"); + // Heard by its send until the next turn, then by its guard only + source.emit("error", failure); + await ioTurn(); + source.emit("error", failure); + await ioTurn(); + deepStrictEqual(reports, [failure]); + }); +}); + +suite("A late error of a source sent once its response has ended", () => { + test("destroyed with an error before its send, which code of the source ends while it is checked: logged once, whatever reports it (R19-C3)", async () => { + const failure = new Error("The source has failed before its send"); + const [received, outcome, captured] = await run(async (context) => { + const source = readable(); + source.destroy(failure); + // A getter of its own that ends the response once `send()` reads it: + // its error is emitted after the send has settled + Object.defineProperty(source, "destroyed", { + get(): boolean { + context.response.end("complete"); + return true; + }, + }); + return outcomeOf(context.send(source)); + }); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:complete|next)/gu), + outcome, + reported(captured), + ], + [ + ["\r\n\r\ncomplete", "\r\n\r\nnext"], + "resolved", + ["The source has failed before its send"], + ], + ); + }); + + test("destroyed with the error of a send that it has decided, which code of the source ends while a second send checks it: never logged (R19-C3)", async () => { + const [received, outcome, captured] = await run(async (context) => { + const { response } = context; + const source = readable(); + response.setHeader("Content-Length", 1); + source.push("too long"); + // Its body is longer than its `Content-Length`: it decides the send, + // which destroys the source with it + const first = await outcomeOf(context.send(source)); + Object.defineProperty(source, "destroyed", { + get(): boolean { + response.end("complete"); + return true; + }, + }); + return [first, await outcomeOf(context.send(source))]; + }); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:8\r\ncomplete|next)/gu), + outcome, + reported(captured), + ], + [ + ["\r\n\r\n8\r\ncomplete", "\r\n\r\nnext"], + [ + "The body (8 bytes) does not match its Content-Length (1 bytes)", + "resolved", + ], + [], + ], + ); + }); + + test("a source destroyed with an error before its send, which rejects with it: never logged", async () => { + const failure = new Error("The source has failed before its send"); + const [received, outcome, captured] = await run(async (context) => { + const source = readable(); + source.destroy(failure); + const sent = await outcomeOf(context.send(source)); + await context.text("replied"); + return sent; + }); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:replied|next)/gu), + outcome, + reported(captured), + ], + [ + ["\r\n\r\nreplied", "\r\n\r\nnext"], + "The source has failed before its send", + [], + ], + ); + }); +}); diff --git a/packages/binden/test/lifecycle.test.ts b/packages/binden/test/lifecycle.test.ts new file mode 100644 index 00000000..99f5c5bb --- /dev/null +++ b/packages/binden/test/lifecycle.test.ts @@ -0,0 +1,472 @@ +import { + Binden, + BindenAbortError, + BindenError, + type Context, + type IBindenOptions, +} from "../index.js"; +import { + type ICaptured, + type IListening, + capture, + listen, + never, + send, + sleep, + within, +} from "./helpers.js"; +import { type ReadStream, createReadStream } from "node:fs"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +/** 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: it removes the `ETag` of the body + context.setHeader("ETag", `"tag"`); + 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" }); + }, + ); + }); + }); +}); + +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); + } + }, + ); + }); + + test("a late send of a Node.js `Readable` releases it without reading it: its file is closed", async () => { + const sources: ReadStream[] = []; + const settled: Promise[] = []; + await withApp( + { timeout: 20 }, + (app) => { + app.use(async (context: Context) => { + await sleep(40); + for (const sent of [ + // Through the context too, which ignores it first + (source: Readable): Promise => context.send(source), + (source: Readable): Promise => context.response.send(source), + ]) { + const source = createReadStream(import.meta.filename); + sources.push(source); + settled.push( + sent(source).then( + () => "resolved", + (error: unknown) => error, + ), + ); + } + await Promise.all(settled); + }); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).status, 503); + await captured.waitFor("A late reply has been ignored"); + }, + ); + deepStrictEqual(await Promise.all(settled), ["resolved", "resolved"]); + for (const source of sources) { + deepStrictEqual([source.readableFlowing, source.bytesRead], [null, 0]); + if (!source.closed) { + await within(once(source, "close")); + } + } + }); +}); diff --git a/packages/binden/test/listeners.test.ts b/packages/binden/test/listeners.test.ts new file mode 100644 index 00000000..93b43a43 --- /dev/null +++ b/packages/binden/test/listeners.test.ts @@ -0,0 +1,710 @@ +import { + Binden, + BindenError, + BindenResponse, + type Context, + type IBindenOptions, +} from "../index.js"; +import { + EventEmitter, + once as eventOnce, + getEventListeners, +} from "node:events"; +import { + type ILateFailures, + onPipeFailure, + openWindow, +} from "../src/listeners.js"; +import { + type IListening, + listen, + never, + send, + sleep, + within, +} from "./helpers.js"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { PassThrough, Readable } from "node:stream"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { kFramework, kSeal, owners } from "../src/seal.js"; +import { suite, test } from "node:test"; +import { Exchange } from "../src/exchange.js"; +import { Socket } from "node:net"; +import { listen as listenHttp } from "./http.js"; + +/** A response without a connection: nothing is actually sent */ +function detached(): BindenResponse { + return new BindenResponse(new IncomingMessage(new Socket())); +} + +/** Runs `fn` with a listening application */ +async function withApp( + options: IBindenOptions, + setup: (app: Binden) => void, + fn: (listening: IListening) => Promise, +): Promise { + const app = new Binden(options); + setup(app); + const listening = await listen(app); + try { + await fn(listening); + } finally { + await listening.close(); + } +} + +/** A promise with its `resolve()` */ +function gate(): { promise: Promise; open(): void } { + const { promise, resolve } = Promise.withResolvers(); + return { promise, open: resolve as () => void }; +} + +/** Whether the error is the `503` of a deadline */ +function isDeadline(error: unknown): boolean { + return error instanceof BindenError && error.code === "deadline"; +} + +suite("Listeners and callbacks of the response", () => { + test("keep the semantics of Node.js", () => { + const response = detached(); + const calls: unknown[][] = []; + const record = (name: string) => + function recorded(this: unknown, ...args: unknown[]): void { + calls.push([name, this === response, ...args]); + }; + const a = record("a"); + const b = record("b"); + const c = record("c"); + const added: unknown[] = []; + response.on("newListener", (event: string, listener: unknown) => { + if (event === "x") { + added.push(listener); + } + }); + response.on("x", a).addListener("x", b).prependListener("x", c); + deepStrictEqual(response.listeners("x"), [c, a, b]); + deepStrictEqual(added, [a, b, c]); + // As Node.js: the listeners themselves (not their binding) + deepStrictEqual(response.rawListeners("x"), [c, a, b]); + response.emit("x", 1, 2); + deepStrictEqual(calls.splice(0), [ + ["c", true, 1, 2], + ["a", true, 1, 2], + ["b", true, 1, 2], + ]); + response.off("x", a).removeListener("x", c); + deepStrictEqual(response.listeners("x"), [b]); + response.removeAllListeners("x"); + deepStrictEqual(response.listenerCount("x"), 0); + + // Once each, also when its event is emitted again while it runs + function once(this: unknown, ...args: unknown[]): void { + calls.push(["once", this === response, ...args]); + response.emit("y", "again"); + } + response.once("y", once).prependOnceListener("y", c); + deepStrictEqual(response.listeners("y"), [c, once]); + response.emit("y", 3); + response.emit("y", 4); + deepStrictEqual(calls.splice(0), [ + ["c", true, 3], + ["once", true, 3], + ]); + deepStrictEqual(response.listenerCount("y"), 0); + // Once, also when an earlier listener emits its event again + let depth = 0; + response.prependListener("w", () => { + depth += 1; + if (depth === 1) { + response.emit("w", "nested"); + } + }); + response.once("w", a); + response.emit("w", "outer"); + deepStrictEqual(calls.splice(0), [["a", true, "nested"]]); + // Removed before it runs + response.once("z", a); + response.removeListener("z", a); + response.emit("z"); + deepStrictEqual(calls, []); + // A listener that throws, or one that is not a function + response.on("throws", () => { + throw new Error("Thrown"); + }); + throws(() => response.emit("throws"), new Error("Thrown")); + throws(() => response.on("x", null as never), { + code: "ERR_INVALID_ARG_TYPE", + }); + }); + + test("run as the work that has added them, without the rights of the framework", () => { + const response = detached(); + const owner = { name: "owner" }; + const other = { name: "other" }; + const seen: unknown[] = []; + const listener = (): void => { + seen.push(owners.getStore()); + response.setHeader("X-Listener", "1"); + }; + owners.run(owner, () => { + response.on("x", listener).once("y", listener); + }); + response.on("z", listener); + // Sealed: only the work of `owner` may change it + response[kSeal]((current) => current === owner); + owners.run(other, () => { + response[kFramework](() => { + response.emit("x"); + response.emit("y"); + response.emit("z"); + }); + }); + deepStrictEqual( + seen.map((store) => store ?? null), + [owner, owner, null], + ); + // The listeners of `owner` have set it, not the one of the other work + deepStrictEqual(response.getHeader("X-Listener"), "1"); + owners.run(owner, () => { + response.removeHeader("X-Listener"); + }); + owners.run(other, () => { + response[kFramework](() => response.emit("z")); + }); + ok(!response.hasHeader("X-Listener")); + }); + + test("its methods may be wrapped on a response, as those of Node.js (e.g. by an instrumentation)", () => { + const names = [ + "on", + "addListener", + "once", + "prependListener", + "prependOnceListener", + "emit", + "rawListeners", + ] as const; + const owner = { name: "owner" }; + const seen: unknown[] = []; + for (const response of [ + detached(), + new ServerResponse(new IncomingMessage(new Socket())), + ]) { + const wrapped: string[] = []; + for (const name of names) { + const original = Reflect.get(response, name) as ( + ...args: unknown[] + ) => unknown; + // An assignment in strict code (a module): it throws when refused + response[name] = function wrapper( + this: ServerResponse, + ...args: unknown[] + ): unknown { + wrapped.push(name); + return Reflect.apply(original, this, args); + } as never; + ok(Object.hasOwn(response, name)); + } + owners.run(owner, () => { + response.on("x", () => { + seen.push(owners.getStore() ?? null); + }); + }); + response.emit("x"); + deepStrictEqual(response.rawListeners("x").length, 1); + deepStrictEqual(wrapped, ["on", "emit", "rawListeners"]); + } + // Still bound to its adder by the method of the response + deepStrictEqual(seen, [owner, null]); + }); + + test("the failure of a pipe goes to the handler that the exchange of its work has set first", () => { + const token = { name: "token" }; + const other = { name: "a work of no exchange" }; + let later = false; + const issuer = { + issued: (candidate: object): boolean => + candidate === token || (candidate === other && later), + }; + const calls: unknown[] = []; + const late: ILateFailures = { + report: (error): void => { + calls.push(["late", error]); + }, + }; + const route = onPipeFailure( + token, + issuer, + (error, source) => { + calls.push([error, source]); + return true; + }, + late, + ); + ok(route !== null); + // Set once: never replaced + deepStrictEqual( + onPipeFailure( + token, + issuer, + () => { + calls.push("replaced"); + return true; + }, + late, + ), + null, + ); + // Not issued by the exchange (yet): not set + deepStrictEqual( + onPipeFailure( + other, + issuer, + () => { + calls.push("other"); + return true; + }, + late, + ), + null, + ); + const thrown = new Error("Thrown"); + const fails = (): never => { + throw thrown; + }; + /** Emits the `end` of a source to a listener added by a work in its window */ + const end = (owner: object): unknown => { + const source = new PassThrough(); + ok(openWindow(source)); + owners.run(owner, () => { + source.once("end", fails); + }); + try { + source.emit("end"); + return source; + } catch (error: unknown) { + return error; + } + }; + const source = end(token); + deepStrictEqual(calls.splice(0), [[thrown, source]]); + later = true; + ok(end(other) === thrown); + // A listener added before its exchange has finished, and one added + // after: late, reported and never thrown + const before = new PassThrough(); + ok(openWindow(before)); + owners.run(token, () => { + before.once("end", fails); + }); + route.fail = null; + before.emit("end"); + ok(end(token) instanceof PassThrough); + deepStrictEqual(calls.splice(0), [ + ["late", thrown], + ["late", thrown], + ]); + // Without a report: still handled + late.report = null; + ok(end(token) instanceof PassThrough); + deepStrictEqual(calls, []); + }); + + test("the failure of a pipe of a work whose exchange has finished is late", async () => { + const response = detached(); + const exchange = Exchange.for(response.req, response); + const token = exchange.token(); + const reported: unknown[] = []; + exchange.onLateFailure((error) => { + reported.push(error); + }); + const thrown = new Error("Thrown"); + const source = new PassThrough(); + ok(openWindow(source)); + owners.run(token, () => { + source.once("end", () => { + throw thrown; + }); + }); + response.emit("close"); + await within(exchange.finished); + // And a token issued once it has finished + const issued = exchange.token(); + const after = new PassThrough(); + ok(openWindow(after)); + owners.run(issued, () => { + after.once("end", () => { + throw thrown; + }); + }); + source.emit("end"); + after.emit("end"); + deepStrictEqual(reported, [thrown, thrown]); + }); + + test("the failure of a pipe of a work whose exchange has no framework is thrown", () => { + const response = detached(); + const token = Exchange.for(response.req, response).token(); + const source = new PassThrough(); + ok(openWindow(source)); + const thrown = new Error("Thrown"); + owners.run(token, () => { + source.once("end", () => { + throw thrown; + }); + }); + throws( + () => source.emit("end"), + (error: unknown) => error === thrown, + ); + }); + + test("the callback of a write runs as the work that has written", () => { + const response = detached(); + const owner = { name: "owner" }; + const seen: unknown[] = []; + owners.run(owner, () => { + response.write("chunk", () => { + seen.push(owners.getStore()); + }); + }); + // Queued until the response has a socket: called later, as whoever + // flushes it then + const { outputData } = response as unknown as { + outputData: { callback(): void }[]; + }; + deepStrictEqual(outputData.length, 1); + owners.run({ name: "other" }, () => { + response[kFramework](() => { + outputData[0]?.callback(); + }); + }); + deepStrictEqual(seen, [owner]); + }); + + test("a `drain` listener of the abandoned work cannot end the reply", async () => { + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + await sleep(40); + // As a compression that emits the `drain` of its compressor + context.response.emit("drain"); + await context.status(503).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + context.response.once("drain", () => { + context.response.end("OLD"); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [503, "handler"]); + }, + ); + }); +}); + +suite("A stream piped into the response", () => { + test("writes as the work that has piped it, whatever work feeds it", async () => { + const source = new PassThrough(); + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).setHeader("Content-Type", "text/plain"); + source.pipe(context.response); + await new Promise((resolve) => { + context.response.once("finish", resolve); + }); + }, + }, + (app) => { + app.use(async (context: Context) => { + // Fed by a timer of the abandoned work (once the deadline has + // passed), which also writes itself + await sleep(40); + const timer = setInterval(() => { + context.response.write("OLD"); + source.write("abc"); + }, 10); + setTimeout(() => { + clearInterval(timer); + context.response.end("OLD"); + source.end("end"); + }, 50); + await never(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual(reply.status, 503); + ok(/^(?:abc)+end$/u.test(reply.body), reply.body); + }, + ); + }); + + test("piped by the abandoned work, writes nothing, whatever work feeds it", async () => { + const source = new PassThrough(); + const piped = gate(); + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + await piped.promise; + // Fed by the work that replies + source.write("OLD"); + source.end("OLD"); + await sleep(20); + await context.status(503).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(40); + source.pipe(context.response); + piped.open(); + await never(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [503, "handler"]); + }, + ); + }); + + test("its listeners are those of Node.js", async () => { + const response = detached(); + const source = Readable.from(["a"]); + source.pipe(response); + // The listeners that the pipe has added, adopted + deepStrictEqual(source.listenerCount("data"), 1); + deepStrictEqual(source.listenerCount("end"), 1); + source.unpipe(response); + deepStrictEqual(source.listenerCount("data"), 0); + deepStrictEqual(source.listenerCount("end"), 0); + // Of a source that has ended: its end is already scheduled + const ended = new PassThrough(); + ended.end(); + ended.resume(); + await new Promise((resolve) => { + ended.once("end", resolve); + }); + ended.pipe(detached()); + deepStrictEqual(ended.listenerCount("data"), 1); + // Not a stream (e.g. emitted by the application) + response.emit("pipe", null); + response.emit("pipe", new EventEmitter()); + ok(response.started); + }); +}); + +/** A listener that records its calls */ +type IRecorded = (this: unknown, ...args: unknown[]) => void; + +/** + * What a sequence of operations on the listeners of a response does (the + * one of R1's review), by identity: its listeners by name, a wrapper of one + * as `wrapper(name)`, and the receiver of each call + */ +async function scenario(response: ServerResponse): Promise { + const out: unknown[] = []; + const other = new EventEmitter(); + const names = new Map(); + const receiver = (self: unknown): string => { + if (self === response) { + return "response"; + } + return self === other ? "other" : typeof self; + }; + const record = (name: string): IRecorded => { + function recorded(this: unknown, ...args: unknown[]): void { + out.push([name, receiver(this), ...args]); + } + names.set(recorded, name); + return recorded; + }; + const id = (fn: unknown): string => { + const name = names.get(fn); + if (typeof name !== "undefined") { + return name; + } + const inner = (fn as { listener?: unknown }).listener; + return names.has(inner) ? `wrapper(${String(names.get(inner))})` : "other"; + }; + const a = record("a"); + const b = record("b"); + const c = record("c"); + response.on("removeListener", (event: unknown, listener: unknown) => { + if (typeof event === "string" && event.startsWith("t")) { + out.push(["removeListener", event, id(listener)]); + } + }); + response.on("newListener", (event: unknown, listener: unknown) => { + if (typeof event === "string" && event.startsWith("t")) { + out.push(["newListener", event, id(listener)]); + } + }); + out.push([ + "returns", + response.on("t1", a) === response, + response.once("t1", b) === response, + response.prependOnceListener("t1", c) === response, + ]); + out.push(["listeners", response.listeners("t1").map(id)]); + out.push(["raw", response.rawListeners("t1").map(id)]); + out.push([ + "count", + response.listenerCount("t1"), + response.listenerCount("t1", a), + response.listenerCount("t1", b), + ]); + out.push(["emit", response.emit("t1", 1), response.emit("t1", 2)]); + // The same listener twice, removed once (the last added) + response.on("t2", a).once("t2", a).removeListener("t2", a); + out.push(["t2", response.rawListeners("t2").map(id)]); + response.emit("t2", "x"); + // The wrapper of a `once()` called directly runs once, removed + response.once("t3", b); + (response.rawListeners("t3")[0] as IRecorded)("direct"); + out.push(["t3", response.listenerCount("t3")]); + response.emit("t3", "no"); + // A raw listener added to another emitter (e.g. by a proxy) + response.on("t4", a).prependListener("t4", c).once("t4", b); + for (const raw of response.rawListeners("t4") as IRecorded[]) { + other.on("t4", raw); + } + other.emit("t4", "copied"); + out.push(["t4", other.listenerCount("t4"), response.listenerCount("t4")]); + response.on("t5", a).once("t5", b).removeAllListeners("t5"); + out.push(["t5", response.listenerCount("t5")]); + setImmediate(() => response.emit("t6", "v6")); + out.push(["events.once", await eventOnce(response, "t6")]); + out.push(["getEventListeners", getEventListeners(response, "t1").map(id)]); + // A listener that throws: the next ones do not run + response.on("t8", () => { + throw new Error("Thrown"); + }); + response.on("t8", a); + try { + response.emit("t8"); + } catch (error: unknown) { + out.push(["t8", (error as Error).message]); + } + // Removed while the event is emitted (Node.js emits to a copy) + const removed = record("removed"); + response.on("t10", (): void => { + response.removeListener("t10", removed); + }); + response.once("t10", removed); + response.emit("t10", "r1"); + response.emit("t10", "r2"); + // A wrapper of `once()` that adds itself again + let count = 0; + const again = (): void => { + count += 1; + out.push(["again", count]); + if (count < 3) { + response.once("t11", again); + } + }; + names.set(again, "again"); + response.once("t11", again); + for (let index = 0; index < 4; index += 1) { + response.emit("t11"); + } + // Removed by the listener itself, once added by `prependOnceListener()` + response.prependOnceListener("t14", a).off("t14", a); + out.push(["t14", response.emit("t14")]); + return out; +} + +suite("Listeners and callbacks compared with a native response", () => { + test("their identities, receivers and events are those of Node.js", async () => { + const native = await scenario( + new ServerResponse(new IncomingMessage(new Socket())), + ); + const binden = await scenario(detached()); + deepStrictEqual(binden, native); + // The scenario covers the wrappers of `once()` + ok(JSON.stringify(native).includes("wrapper(b)")); + }); + + test("the callbacks of `write()` and `end()` get the receiver that Node.js gives them", async () => { + const receivers = async ( + Response: typeof BindenResponse, + ): Promise => { + const server = await listenHttp({ Response, plain: true }); + try { + const seen: string[] = []; + const handled = server.handle( + (request, response) => + new Promise((resolve) => { + const kind = (self: unknown): string => { + if (self === response) { + return "response"; + } else if (self === request.socket) { + return "socket"; + } + return typeof self === "undefined" + ? "undefined" + : `other ${Object.prototype.toString.call(self)}`; + }; + function written(this: unknown): void { + seen.push(`write ${kind(this)}`); + } + function encoded(this: unknown): void { + seen.push(`write(encoding) ${kind(this)}`); + } + function ended(this: unknown): void { + seen.push(`end ${kind(this)}`); + resolve(); + } + response.write("a", written); + response.write("b", "latin1", encoded); + response.end("c", ended); + }), + ); + await server.request(); + await handled; + return seen.sort(); + } finally { + await server.close(); + } + }; + const native = await receivers(ServerResponse as typeof BindenResponse); + deepStrictEqual(await receivers(BindenResponse), native); + deepStrictEqual(native.length, 3); + }); + + test("a listener added to a piped source gets the source, as with Node.js", async () => { + const receiverOf = async (response: ServerResponse): Promise => { + const source = new PassThrough(); + const { promise, resolve } = Promise.withResolvers(); + function received(this: unknown): void { + resolve(this === source); + } + response.prependListener("pipe", (piped: EventEmitter) => { + piped.on("data", received); + }); + source.pipe(response); + source.write("x"); + return within(promise); + }; + deepStrictEqual( + [ + await receiverOf(new ServerResponse(new IncomingMessage(new Socket()))), + await receiverOf(detached()), + ], + [true, true], + ); + }); +}); diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts new file mode 100644 index 00000000..f4c13f50 --- /dev/null +++ b/packages/binden/test/logger.test.ts @@ -0,0 +1,643 @@ +import { + BindenAbortError, + BindenError, + BindenRequest, + BindenResponse, + Context, +} from "../index.js"; +import { type ILogger, Logger } from "@binden/logger"; +import { + childLogger, + createSerializers, + isLogger, + logTo, + record, + safe, + serializers, + snapshot, +} from "../src/logger.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Socket } from "node:net"; + +const secrets = [ + "secret-token", + "secret-cookie", + "secret-query", + "secret-set-cookie", + "secret-api-key", + "secret-header", + "secret-session", + "secret-body", + "secret-message", + "secret-cause", + "secret-json", +]; + +function createContext(): Context { + const request = new BindenRequest(new Socket()); + request.method = "GET"; + request.url = "/path?token=secret-query"; + request.headers = { + host: "localhost", + "user-agent": "test", + authorization: "Bearer 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"); + // 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 { + for (const secret of secrets) { + ok(!output.includes(secret), `${secret} leaked: ${output}`); + } +} + +/** 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 and response: an allowlist of details", () => { + const { request, response } = createContext(); + + deepStrictEqual(serializers.request(request), { + method: "GET", + path: "/path", + }); + deepStrictEqual(serializers.response(response), { status: 200 }); + deepStrictEqual(serializers.request("secret"), { type: "string" }); + }); + + test("context: not its own fields", () => { + const context = createContext(); + + deepStrictEqual(serializers.context(context), { + id: context.id, + request: { method: "GET", path: "/path" }, + response: { status: 200 }, + }); + assertNoSecrets(JSON.stringify(serializers.context(context))); + deepStrictEqual(serializers.context("secret"), { type: "string" }); + }); + + test("error: the type, a known code and the status only", () => { + 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, + }); + deepStrictEqual( + 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(new BindenAbortError("deadline")), { + type: "BindenAbortError", + }); + class SECRET_CLASS extends RangeError {} + deepStrictEqual(serializers.error(new SECRET_CLASS()), { + type: "RangeError", + }); + 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" }); + }); + + test("body", () => { + deepStrictEqual(serializers.body(), "[Redacted]"); + }); + + 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(), + body: { password: "secret-body" }, + error: new BindenError(404, { + message: "secret-message", + cause: new Error("secret-cause"), + }), + }); + + deepStrictEqual(lines.length, 1); + assertNoSecrets(lines.join("")); + ok(lines.join("").includes("[Redacted]")); + }); +}); + +suite("createSerializers()", () => { + /** The records that a logger with the serializers writes */ + const records = ( + options: ConstructorParameters[0], + ): Record[] => { + const lines: string[] = []; + const log = new Logger({ + ...options, + level: "debug", + destination: { + write: (line: string): void => { + lines.push(line); + }, + }, + }); + const error = Object.assign(new TypeError("the message"), { + code: "E_APP", + cause: new Error("the cause"), + }); + log.debug("Failed", { error, body: { field: "value" } }); + return lines.map((line) => JSON.parse(line) as Record); + }; + + test("the details of the errors, only with `error_details`", () => { + const error = Object.assign(new TypeError("the message"), { + code: "E_APP", + status: 409, + cause: new Error("the cause"), + }); + const detailed = createSerializers({ error_details: true }).error(error); + const { stack } = detailed; + const cause = detailed.cause as Record; + deepStrictEqual( + { ...detailed, stack: typeof stack, cause: cause.message }, + { + type: "TypeError", + code: "E_APP", + status: 409, + message: "the message", + stack: "string", + cause: "the cause", + }, + ); + for (const plain of [createSerializers(), serializers]) { + deepStrictEqual(plain.error(error), { type: "TypeError", status: 409 }); + // `err`, the key of `pino`, as well + deepStrictEqual(plain.err(error), { type: "TypeError", status: 409 }); + } + deepStrictEqual( + createSerializers({ error_details: true }).err(error), + detailed, + ); + // The other serializers are the same + const { body, context, request, response } = createSerializers({ + error_details: true, + }); + deepStrictEqual(body({ password: "secret" }), "[Redacted]"); + const created = createContext(); + deepStrictEqual(context(created), serializers.context(created)); + deepStrictEqual(request(created.request), { method: "GET", path: "/path" }); + deepStrictEqual(response(created.response), { status: 200 }); + }); + + test("invalid `error_details`", () => { + for (const error_details of [1, "true", null]) { + throws( + () => + createSerializers({ + error_details: error_details as unknown as boolean, + }), + new TypeError("`error_details` is not a boolean"), + ); + } + }); + + test("a logger with them logs the details of the errors", () => { + const [detailed] = records({ + serializers: { + ...createSerializers({ error_details: true }), + body: (body: unknown): unknown => body, + }, + }); + const error = detailed.error as Record; + deepStrictEqual( + [error.type, error.message, detailed.body], + ["TypeError", "the message", { field: "value" }], + ); + // The serializers of binden replace the `error_details` of the logger + const [plain] = records({ error_details: true, serializers }); + deepStrictEqual( + [plain.error, plain.body], + [{ type: "TypeError" }, "[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 a new logger of binden over 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"); + }, + }); + const invalid = safe({ + ...base, + child: (): ILogger => ({}) as ILogger, + }); + for (const logger of [throwing, invalid]) { + const child = logger.child({ id: 1 }); + // Never the logger itself, whose methods the application may replace + ok(child !== logger && safe(child) === child); + child.info("itself"); + } + const valid = safe(base).child({ id: 1 }); + valid.info("child"); + deepStrictEqual(logged, ["itself", "itself", "child"]); + }); + + test("an object derived from a logger of binden, or a proxy of one, is wrapped: it logs through that logger, and a method that it overrides is used", () => { + const logged: string[] = []; + const base = safe( + recorder((message: string) => { + logged.push(message); + }), + ); + const derived = Object.assign(Object.create(base) as ILogger, { + warn(message: string): void { + logged.push(`overridden ${message}`); + }, + }); + const traps: string[] = []; + const handler = new Proxy( + {}, + { + // No trap at all: each is read from the handler, and recorded + get(_target: object, trap: string): null { + traps.push(trap); + return null; + }, + }, + ); + const proxy = new Proxy(base, handler); + const wrapped = [safe(derived), safe(proxy)]; + // Told by its brand: no trap of the proxy has run + deepStrictEqual(traps, []); + ok(wrapped.every((logger) => logger !== derived && logger !== proxy)); + for (const logger of wrapped) { + logger.error("error"); + logger.warn("warn"); + logger.child({ id: 1 }).info("child"); + } + // Also its inherited methods, called on it + derived.debug("inherited"); + derived.child({ id: 2 }).trace("inherited child"); + deepStrictEqual(logged, [ + "error", + "overridden warn", + "child", + "error", + "warn", + "child", + "inherited", + "inherited child", + ]); + }); + + test("a `child()` that gives a logger that throws gives a logger of binden over it, whose calls never fail", () => { + const calls: string[] = []; + const throwing = recorder((message: string): never => { + calls.push(message); + throw new Error("A failing child"); + }); + const parent = safe({ ...recorder(() => null), child: () => throwing }); + const child = parent.child({ id: 1 }); + child.error("error"); + childLogger(parent, { id: 2 }).warn("warn"); + // Another logger is wrapped first, and so is a value that is not one + const wrapped = childLogger(throwing, { id: 3 }); + wrapped.info("info"); + const nothing = childLogger(null as unknown as ILogger, { id: 4 }); + nothing.error("nowhere"); + ok(safe(nothing) === nothing); + // A level that is not one drops the record, without converting it + record(throwing, "verbose" as "info", "dropped"); + // A logger of binden, told by its brand + ok(safe(child) === child && safe(wrapped) === wrapped); + deepStrictEqual(calls, ["error", "warn", "info"]); + }); + + test("binden writes its own records by the private paths of its loggers: the methods (and `child()`) replaced on one are never used, and the class has no property to replace", () => { + const calls: unknown[][] = []; + const base = safe( + recorder((...args: unknown[]) => { + calls.push(args); + }), + ); + const log = base.child({ id: 1 }); + const thrower = (): never => { + throw new Error("A replaced method"); + }; + for (const method of [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + "child", + ]) { + Object.assign(log, { [method]: thrower }); + } + record(log, "info", "info"); + record(log, "error", "error", { a: 1 }); + const child = childLogger(log, { id: 2 }); + ok(safe(child) === child); + record(child, "debug", "child"); + // A new logger of binden over a logger of binden that a `child()` gives: + // also its own methods reach it by its private path + const over = safe({ ...recorder(thrower), child: () => log }).child({}); + ok(over !== log && safe(over) === over); + over.warn("over"); + record(over, "trace", "recorded over"); + // Its child: by the private path of the logger of binden under it + over.child({ id: 3 }).info("over child"); + record(childLogger(over, { id: 4 }), "fatal", "recorded over child"); + // The application wraps them, and calls its own methods + throws(() => { + log.info("replaced"); + }); + deepStrictEqual(calls, [ + ["info"], + ["error", { a: 1 }], + ["child"], + ["over"], + ["recorded over"], + ["over child"], + ["recorded over child"], + ]); + deepStrictEqual(Object.getOwnPropertyNames(log.constructor).sort(), [ + "length", + "name", + "prototype", + ]); + }); + + test("record() and logTo() never throw: another logger is called guarded, and what getting the logger of a context throws drops the record", async () => { + const logged: string[] = []; + const base = safe( + recorder((message: string) => { + logged.push(message); + }), + ); + const thrower = (): never => { + throw new Error("A failing logger"); + }; + record(null, "info", "nothing"); + record(recorder(thrower), "info", "thrown"); + let observed = false; + record( + recorder(() => ({ + then(_resolve: unknown, reject: (error: Error) => void): void { + observed = true; + reject(new Error("A rejecting logger")); + }, + })), + "warn", + "rejected", + ); + // An object derived from a logger of binden: through its own methods + const derived = Object.assign(Object.create(base) as ILogger, { + warn(message: string): void { + logged.push(`overridden ${message}`); + }, + }); + record(derived, "warn", "derived"); + record(derived, "debug", "inherited"); + logTo( + { + get log(): ILogger { + return thrower(); + }, + }, + "info", + "dropped", + ); + class Unlogged extends Context { + // eslint-disable-next-line class-methods-use-this -- a getter that fails + public override get log(): ILogger { + return thrower(); + } + } + const request = new BindenRequest(new Socket()); + const response = new BindenResponse(request); + logTo(new Unlogged({ request, response }), "error", "dropped"); + const context = new Context({ request, response, logger: base }); + logTo(context, "error", "through the context", { a: 1 }); + await new Promise((resolve) => { + setImmediate(resolve); + }); + ok(observed); + deepStrictEqual(logged, [ + "overridden derived", + "inherited", + "through the context", + ]); + }); + + test("a logger of binden over another one is flat: nested 20000 times (by its constructor, or by a `child()` that gives the last child), it writes every record and never throws", () => { + const logged: string[] = []; + const base = recorder((message: string) => { + logged.push(message); + }); + // The class of the loggers of binden, which the application reaches + const Safe = safe(base).constructor as new (logger: ILogger) => ILogger; + let nested: ILogger = base; + for (let index = 0; index < 20_000; index += 1) { + nested = new Safe(nested); + } + let last: ILogger | null = null; + // E.g. a cache that keeps the last `context.log` + const caching: ILogger = { ...base, child: (): ILogger => last ?? base }; + for (let index = 0; index < 20_000; index += 1) { + last = childLogger(caching, { index }); + } + for (const logger of [nested, last as unknown as ILogger]) { + logger.info("method"); + record(logger, "warn", "record"); + logger.child({ id: 1 }).error("method child"); + record(childLogger(logger, { id: 2 }), "debug", "record child"); + const request = new BindenRequest(new Socket()); + const response = new BindenResponse(request); + new Context({ request, response, logger }).log.trace("context"); + } + deepStrictEqual(logged, [ + ...["method", "record", "method child", "record child", "context"], + ...["method", "record", "method child", "record child", "context"], + ]); + }); + + 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/test/middleware.test.ts b/packages/binden/test/middleware.test.ts similarity index 76% rename from test/middleware.test.ts rename to packages/binden/test/middleware.test.ts index ef99975c..1e16c714 100644 --- a/test/middleware.test.ts +++ b/packages/binden/test/middleware.test.ts @@ -1,4 +1,5 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; import { Middleware } from "../index.js"; @@ -32,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/native-pipes.test.ts b/packages/binden/test/native-pipes.test.ts new file mode 100644 index 00000000..9ed627f8 --- /dev/null +++ b/packages/binden/test/native-pipes.test.ts @@ -0,0 +1,921 @@ +import { type AddressInfo, connect } from "node:net"; +import { + Binden, + BindenError, + type Context, + type IBindenOptions, +} from "../index.js"; +import { EventEmitter, once } from "node:events"; +import { + type ICaptured, + capture, + ioTurn, + listen, + never, + pollUntil, + sleep, + within, +} from "./helpers.js"; +import { PassThrough, Readable, pipeline } from "node:stream"; +import { createServer, get } from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { owners } from "../src/seal.js"; + +/** A listener, as an emitter has it */ +type IListener = (...args: unknown[]) => unknown; + +/** The context that `otel()` binds the listeners of an emitter to */ +const contexts = new AsyncLocalStorage(); + +/** A listener bound to the context of `otel()` */ +function bindTo(listener: IListener): IListener { + return function bound(this: unknown, ...args: unknown[]): unknown { + return contexts.run("span", () => Reflect.apply(listener, this, args)); + }; +} + +/** + * Binds the listeners of an emitter to a context, as `context.bind()` of + * OpenTelemetry does (`AsyncLocalStorageContextManager`, for the requests + * and responses of its `http` instrumentation): listener methods of its own + * that wrap every listener, and that unwrap one to remove it + */ +function otel(emitter: T): T { + const patches = new Map>(); + const state = { wrapping: false }; + const define = (name: string, value: IListener): void => { + Object.defineProperty(emitter, name, { + configurable: true, + writable: true, + value, + }); + }; + for (const name of [ + "addListener", + "on", + "once", + "prependListener", + "prependOnceListener", + ]) { + const original = Reflect.get(emitter, name) as IListener; + define(name, function bind(this: T, event, listener): unknown { + if (state.wrapping) { + return Reflect.apply(original, this, [event, listener]); + } + const bound = bindTo(listener as IListener); + const listeners = patches.get(event) ?? new WeakMap(); + patches.set(event, listeners); + listeners.set(listener as IListener, bound); + state.wrapping = true; + try { + return Reflect.apply(original, this, [event, bound]); + } finally { + state.wrapping = false; + } + }); + } + for (const name of ["removeListener", "off"]) { + const original = Reflect.get(emitter, name) as IListener; + define(name, function unbind(this: T, event, listener): unknown { + const bound = patches.get(event)?.get(listener as IListener); + return Reflect.apply(original, this, [event, bound ?? listener]); + }); + } + const removeAll = Reflect.get(emitter, "removeAllListeners") as IListener; + define("removeAllListeners", function clear(this: T, ...args): unknown { + if (args.length === 0) { + patches.clear(); + } else { + patches.delete(args[0]); + } + return Reflect.apply(removeAll, this, args); + }); + return emitter; +} + +/** Whether the error is the `503` of a deadline */ +function isDeadline(error: unknown): boolean { + return error instanceof BindenError && error.code === "deadline"; +} + +/** What a client has received on its connection, and whether it is closed */ +interface IRaw { + readonly raw: string; + readonly closed: boolean; +} + +/** Whether a complete response has come (framed by its length, or by its last chunk) */ +function complete(raw: string): boolean { + const end = raw.indexOf("\r\n\r\n"); + if (end === -1) { + return false; + } + const length = /^Content-Length: (?\d+)$/imu.exec(raw.slice(0, end)) + ?.groups?.n; + return typeof length === "undefined" + ? raw.endsWith("\r\n0\r\n\r\n") + : raw.length >= end + 4 + Number(length); +} + +/** + * Sends a request on a new connection, then reads what comes until the + * connection is closed, or for `ms` once a complete response has come (it + * may take longer on a loaded machine: within 10 s), so that what would + * follow it is seen (judged after a turn of the pending I/O; once the end of + * the connection has been read, by its `close` itself) + */ +async function exchange( + port: number, + { method = "GET", body = "", ms = 300 } = {}, +): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + const received = { raw: "", closed: false }; + const { promise: closing, resolve: close } = Promise.withResolvers(); + socket.setEncoding("latin1").on("data", (chunk: string) => { + received.raw += chunk; + }); + socket.on("close", () => { + received.closed = true; + close(null); + }); + socket.write( + `${method} / HTTP/1.1\r\nHost: localhost\r\nContent-Length: ${body.length}\r\n\r\n${body}`, + ); + await pollUntil(() => received.closed || complete(received.raw)).catch( + () => null, + ); + const deadline = Date.now() + ms; + while (!received.closed && Date.now() < deadline) { + await sleep(5); + } + await ioTurn(); + if (socket.readableEnded) { + // Its `close` follows + await within(closing); + } + socket.destroy(); + return { + raw: received.raw + .replace(/\r\nDate: [^\r]*/u, "") + .replace(/\r\nKeep-Alive: [^\r]*/u, ""), + closed: received.closed, + }; +} + +/** The status and the body of a complete response */ +function replied({ raw }: IRaw): [number, string] { + const end = raw.indexOf("\r\n\r\n"); + const status = Number(raw.slice(9, 12)); + const length = Number(/^Content-Length: (?\d+)$/imu.exec(raw)?.groups?.n); + const body = raw.slice(end + 4); + if (Number.isNaN(length)) { + // Chunked + let decoded = ""; + let rest = body; + for (;;) { + const line = rest.indexOf("\r\n"); + const size = Number.parseInt(rest.slice(0, line), 16); + if (!(size > 0)) { + return [status, decoded]; + } + decoded += rest.slice(line + 2, line + 2 + size); + rest = rest.slice(line + 2 + size + 2); + } + } + return [status, body.slice(0, length)]; +} + +/** Runs `fn` with a listening application (with a captured debug logger) */ +async function withApp( + options: IBindenOptions, + setup: (app: Binden) => void, + fn: (port: number, 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.port, captured); + } finally { + await listening.close(); + } +} + +/** The reasons logged with a message */ +function reasons(captured: ICaptured, message: string): unknown[] { + return captured.records + .filter(({ msg }) => msg === message) + .map(({ reason }) => reason); +} + +/** Whether the framework has attempted a reply (it must not, once destroyed) */ +function attempted(captured: ICaptured): boolean { + return captured.records.some( + ({ msg }) => + msg === "The request has failed" || + msg === "The error reply has not been sent", + ); +} + +/** The message of a pipe that the stack leaves to Node.js */ +const left = "A pipe has been left to Node.js"; + +/** The message of a pipe of a revoked stack that is not proven dismantled */ +const unproven = + "A pipe left to Node.js by an abandoned work may still write. Destroying the socket"; + +/** Sets the state of a source as if it had emitted its `end` */ +function saysEnded(source: PassThrough): void { + const state = Reflect.get(source, "_readableState") as { + endEmitted: boolean; + }; + state.endEmitted = true; +} + +/** Feeds a source from a timer of no request: its chunks, then its end */ +function feed(source: PassThrough, chunks: readonly string[], ms = 5): void { + owners.exit(() => { + setTimeout(() => { + for (const chunk of chunks) { + source.write(chunk); + } + source.end(); + }, ms); + }); +} + +suite( + "A pipe of the stack whose source has listener methods of its own", + () => { + for (const name of [ + "on", + "addListener", + "once", + "prependListener", + "prependOnceListener", + "removeListener", + "off", + "removeAllListeners", + ]) { + test(`(its own \`${name}()\`) is left to Node.js: as without binden`, async () => { + await withApp( + {}, + (app) => { + app.use(async ({ response }: Context) => { + response.type("text/plain"); + const source = new PassThrough(); + const inherited = Reflect.get( + PassThrough.prototype, + name, + ) as IListener; + Object.defineProperty(source, name, { + configurable: true, + writable: true, + value(this: PassThrough, ...args: unknown[]): unknown { + return Reflect.apply(inherited, this, args); + }, + }); + source.pipe(response); + feed(source, ["REAL"]); + await never(); + }); + }, + async (port, captured) => { + const reply = await exchange(port); + deepStrictEqual(replied(reply), [200, "REAL"]); + deepStrictEqual(reasons(captured, left), [ + `the source has its own ${name}()`, + ]); + }, + ); + }); + } + + test("starts the response: a middleware that returns at once leaves it to its pipe", async () => { + await withApp( + {}, + (app) => { + app.use(({ response }: Context) => { + response.type("text/plain"); + const source = otel(new PassThrough()); + source.pipe(response); + feed(source, ["REAL"]); + }); + }, + async (port) => { + deepStrictEqual(replied(await exchange(port)), [200, "REAL"]); + }, + ); + }); + + suite("bound by OpenTelemetry (its `http` instrumentation)", () => { + /** An upstream server whose reply is a body of its own */ + const upstream = async (): Promise<{ + port: number; + close(): Promise; + }> => { + const server = createServer((_request, response) => { + response.setHeader("Content-Type", "text/plain"); + response.end("UPSTREAM"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + port: (server.address() as AddressInfo).port, + close: async (): Promise => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }, + }; + }; + + for (const kind of ["echo", "proxy pipe", "proxy pipeline"]) { + test(`${kind}: the reply of the stack`, async () => { + const up = await upstream(); + try { + await withApp( + {}, + (app) => { + app.use(async ({ request, response }: Context) => { + // As the instrumentation binds them + otel(request); + otel(response); + if (kind === "echo") { + request.pipe(response); + await once(response, "close"); + return; + } + const source = await new Promise((resolve) => { + get({ port: up.port, host: "127.0.0.1" }, (proxied) => { + resolve(otel(proxied)); + }); + }); + if (kind === "proxy pipe") { + source.pipe(response); + await once(response, "close"); + } else { + await new Promise((resolve) => { + pipeline(source, response, resolve); + }); + } + }); + }, + async (port, captured) => { + const reply = await exchange(port, { + method: "POST", + body: "ECHO", + }); + deepStrictEqual(replied(reply), [ + 200, + kind === "echo" ? "ECHO" : "UPSTREAM", + ]); + deepStrictEqual(reasons(captured, left), [ + "the source has its own on()", + ]); + }, + ); + } finally { + await up.close(); + } + }); + } + + test("a deadline before any byte: the connection is closed, nothing replaces the reply", async () => { + await withApp( + { timeout: 20 }, + (app) => { + app.use(async ({ response }: Context) => { + otel(new PassThrough()).pipe(response); + await never(); + }); + }, + async (port, captured) => { + const reply = await exchange(port); + deepStrictEqual(reply, { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + ok( + captured.records.some( + ({ msg }) => + msg === + "The response has been destroyed: nothing replaces the reply", + ), + ); + ok(!attempted(captured), captured.output()); + }, + ); + }); + + test("a deadline once its source has ended (`{ end: false }`): the connection is closed all the same", async () => { + await withApp( + { timeout: 30 }, + (app) => { + app.use(async ({ response }: Context) => { + const source = otel(new PassThrough()); + source.end(); + source.pipe(response, { end: false }); + await once(source, "end"); + await never(); + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + ok(!attempted(captured), captured.output()); + }, + ); + }); + + test("a deadline once its pipe has ended the response, which waits for the one before it: the reply is kept", async () => { + await withApp( + { timeout: 100 }, + (app) => { + app.use(async (context: Context) => { + const { request, response } = context; + response.type("text/plain"); + if (request.url === "/first") { + // Replies after the deadline of the request after it + context.setTimeout(0); + await sleep(250); + await context.send("FIRST"); + return; + } + const source = otel(new PassThrough()); + source.pipe(response); + feed(source, ["REAL"]); + await once(source, "end"); + await never(); + }); + }, + async (port, captured) => { + // Pipelined: the second response waits for the first one + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let raw = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + raw += chunk; + }); + socket.write( + "GET /first HTTP/1.1\r\nHost: localhost\r\n\r\nGET / HTTP/1.1\r\nHost: localhost\r\n\r\n", + ); + await pollUntil(() => raw.endsWith("REAL\r\n0\r\n\r\n")).catch( + () => null, + ); + await sleep(20); + await ioTurn(); + const { closed } = socket; + socket.destroy(); + deepStrictEqual( + [...raw.matchAll(/HTTP\/1\.1 (?\d+)/gu)].map( + ({ groups }) => groups?.code, + ), + ["200", "200"], + ); + ok(raw.includes("FIRSTHTTP/1.1 200 OK"), raw); + ok(raw.endsWith("\r\n\r\n4\r\nREAL\r\n0\r\n\r\n"), raw); + ok(!closed); + deepStrictEqual(reasons(captured, left), [ + "the source has its own on()", + ]); + await captured.waitFor( + "The deadline has passed once the response was complete. It is sent after the responses before it", + ); + deepStrictEqual(reasons(captured, unproven), []); + }, + ); + }); + + test("a failure of a pipe of the stack once its pipe has finished the response: the reply is kept", async () => { + await withApp( + {}, + (app) => { + app.use(async ({ response }: Context) => { + response.type("text/plain"); + const source = otel(new PassThrough()); + source.pipe(response); + feed(source, ["REAL"]); + await once(response, "finish"); + // Refused (and the stack fails): its source has listeners of + // its listeners + const refused = new PassThrough(); + refused.on("newListener", () => null); + refused.pipe(response); + await never(); + }); + }, + async (port, captured) => { + const reply = await exchange(port); + deepStrictEqual(replied(reply), [200, "REAL"]); + ok(!reply.closed); + deepStrictEqual(reasons(captured, left), [ + "the source has its own on()", + ]); + deepStrictEqual(reasons(captured, unproven), []); + }, + ); + }); + + test("`context.send()` of such a stream, which binden reads itself: the 503 of a deadline", async () => { + await withApp( + { timeout: 20 }, + (app) => { + app.use(async (context: Context) => { + await context.send(otel(new PassThrough())); + }); + }, + async (port, captured) => { + const reply = await exchange(port); + deepStrictEqual(replied(reply)[0], 503); + deepStrictEqual(reasons(captured, left), []); + }, + ); + }); + + test("whose `close` listeners the stack has removed: the connection is closed at the deadline", async () => { + await withApp( + { timeout: 20 }, + (app) => { + app.use(async ({ response }: Context) => { + otel(new PassThrough()).pipe(response); + response.removeAllListeners("close"); + await never(); + }); + }, + async (port) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + }, + ); + }); + + for (const handler of ["deadline", "thrown"]) { + test(`of an error handler (${handler === "deadline" ? "of a deadline" : "of a thrown error"}): refused, 500`, async () => { + await withApp( + { + ...(handler === "deadline" ? { timeout: 20 } : {}), + error_handler: async (context: Context): Promise => { + context.response.status(503); + const source = otel(new PassThrough()); + source.pipe(context.response); + feed(source, ["HANDLER"]); + await never(); + }, + }, + (app) => { + app.use(async () => { + if (handler === "thrown") { + throw new BindenError(409); + } + await never(); + }); + }, + async (port, captured) => { + const reply = await exchange(port); + deepStrictEqual(replied(reply), [500, ""]); + ok(!reply.raw.includes("HANDLER")); + await captured.waitFor("The provided `error_handler` has failed"); + deepStrictEqual(reasons(captured, left), []); + }, + ); + }); + } + }); + + suite( + "once the stack is revoked, before anything replaces its reply", + () => { + for (const spoof of [ + "none", + "endEmitted", + "readableEnded", + "emit", + ] as const) { + test(`own \`on()\` and \`removeListener()\` that leave the listener of the pipe (says it has ended: ${spoof}): the connection is closed, nothing of it is sent`, async () => { + const shared = new PassThrough(); + await withApp( + { + timeout: 20, + error_handler: async ( + context: Context, + error, + ): Promise => { + if (isDeadline(error)) { + // The reply of the error handler, through the shared source + shared.write("OLD"); + await context.status(503).send("handler"); + } + }, + }, + (app) => { + app.use(async ({ response }: Context) => { + const extra = (): null => null; + const on = Reflect.get( + PassThrough.prototype, + "on", + ) as IListener; + const remove = Reflect.get( + EventEmitter.prototype, + "removeListener", + ) as IListener; + Object.assign(shared, { + on(this: PassThrough, event: string, listener: IListener) { + Reflect.apply(on, this, [event, listener]); + if (event === "data" && listener !== extra) { + Reflect.apply(on, this, [event, extra]); + } + return this; + }, + removeListener( + this: PassThrough, + event: string, + listener: IListener, + ) { + return Reflect.apply(remove, this, [ + event, + event === "data" ? extra : listener, + ]); + }, + }); + shared.pipe(response); + // At its revocation, before the pipe is dismantled: the + // source says that it has ended, then flows again + response.once("close", () => { + if (spoof === "endEmitted") { + saysEnded(shared); + } else if (spoof === "readableEnded") { + Object.defineProperty(shared, "readableEnded", { + configurable: true, + value: true, + }); + } else if (spoof === "emit") { + shared.emit("end"); + } + process.nextTick(() => shared.resume()); + }); + await never(); + }); + }, + async (port, captured) => { + const reply = await exchange(port); + // Fed after the deadline as well + shared.write("LATE"); + deepStrictEqual(reply, { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + ok(!attempted(captured), captured.output()); + }, + ); + }); + } + + test("own `on()` that wraps the end of the pipe, and `removeListener()` that keeps it: the connection is closed, nothing of it ends a reply", async () => { + const shared = new PassThrough(); + const state = { allow: false, handled: false }; + const on = Reflect.get(EventEmitter.prototype, "on") as IListener; + const remove = Reflect.get( + EventEmitter.prototype, + "removeListener", + ) as IListener; + Object.assign(shared, { + on(this: PassThrough, event: string, listener: IListener) { + if (event !== "end") { + return Reflect.apply(on, this, [event, listener]); + } + // As the wrapper of a `once()`: its call is suppressed until + // allowed + const wrapper = Object.assign( + function wrapped(this: unknown, ...args: unknown[]): unknown { + return state.allow + ? Reflect.apply(listener, this, args) + : null; + }, + { listener }, + ); + return Reflect.apply(on, this, [event, wrapper]); + }, + removeListener( + this: PassThrough, + event: string, + listener: IListener, + ) { + return event === "end" + ? this + : Reflect.apply(remove, this, [event, listener]); + }, + }); + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (isDeadline(error)) { + state.handled = true; + context.response.status(503); + // The end of the shared source, which the wrapper now lets + // through to the end of the pipe + state.allow = true; + shared.emit("end"); + await context.send("handler"); + } + }, + }, + (app) => { + app.use(async ({ response }: Context) => { + shared.pipe(response); + response.once("close", () => { + // Its end, which the wrapper suppresses, and the state of + // a source that has ended + shared.emit("end"); + saysEnded(shared); + }); + await never(); + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + ok(!state.handled); + ok(!attempted(captured), captured.output()); + }, + ); + }); + + test("by a pipe of the stack that fails: the connection is closed instead of the 500", async () => { + await withApp( + {}, + (app) => { + app.use(async ({ response }: Context) => { + otel(new PassThrough()).pipe(response); + // Refused (and its work fails): a method of its own other than + // a listener method + const refused = new PassThrough(); + Object.defineProperty(refused, "emit", { + configurable: true, + writable: true, + value: Reflect.get( + PassThrough.prototype, + "emit", + ) as IListener, + }); + refused.pipe(response); + await never(); + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + // Its failure, which nothing replies to + const record = await captured.waitFor( + "A pipe into the response has failed once it was destroyed", + ); + deepStrictEqual( + [record.level, (record.failure as { type: string }).type], + [50, "TypeError"], + ); + ok(!attempted(captured), captured.output()); + }, + ); + }); + + test("by a pipe of the error handler that fails (its thrown error): the connection is closed instead of the 500", async () => { + await withApp( + { + error_handler: async (context: Context): Promise => { + const refused = new PassThrough(); + refused.on("newListener", () => null); + refused.pipe(context.response); + await never(); + }, + }, + (app) => { + app.use(({ response }: Context) => { + otel(new PassThrough()).pipe(response); + throw new BindenError(409); + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + await captured.waitFor( + "The response has been destroyed: nothing replaces the reply", + ); + ok(!attempted(captured), captured.output()); + }, + ); + }); + + test("whose source has ended, and whose class does not unpipe it: the connection is closed", async () => { + /** A class that keeps its pipes */ + class Kept extends PassThrough { + public override unpipe(): this { + return this; + } + } + await withApp( + { timeout: 30 }, + (app) => { + app.use(async ({ response }: Context) => { + const source = otel(new Kept()); + source.end(); + source.pipe(response, { end: false }); + await once(source, "end"); + await never(); + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + }, + ); + }); + + test("whose source has got an accessor of its own since (e.g. that says it has ended): the connection is closed", async () => { + await withApp( + { timeout: 20 }, + (app) => { + app.use(async ({ response }: Context) => { + const source = otel(new PassThrough()); + source.pipe(response); + Object.defineProperty(source, "readableEnded", { + configurable: true, + get: (): boolean => true, + }); + await never(); + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + deepStrictEqual(reasons(captured, unproven), [ + "the source has its own on()", + ]); + }, + ); + }); + + test("whose unpipe throws (its own `removeListener()`): logged, and the connection is closed", async () => { + await withApp( + { timeout: 20 }, + (app) => { + app.use(async ({ response }: Context) => { + const source = otel(new PassThrough()); + source.pipe(response); + Object.defineProperty(source, "removeListener", { + configurable: true, + writable: true, + value(): never { + throw new Error("The cleanup has failed"); + }, + }); + // Unpiped by the framework only (not by its `close` listener) + response.removeAllListeners("close"); + await never(); + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), { raw: "", closed: true }); + await captured.waitFor( + "The cleanup of a pipe left to Node.js has failed", + ); + }, + ); + }); + + test("a pipe that the revoked stack starts later is refused, not left to Node.js", async () => { + const source = new PassThrough(); + await withApp( + { timeout: 20 }, + (app) => { + app.use(async ({ response }: Context) => { + await sleep(40); + otel(source).pipe(response); + feed(source, ["LATE"]); + }); + }, + async (port, captured) => { + const reply = await exchange(port, { ms: 150 }); + deepStrictEqual(replied(reply)[0], 503); + ok(!reply.raw.includes("LATE"), reply.raw); + await captured.waitFor("A pipe of an abandoned work has failed"); + deepStrictEqual(reasons(captured, left), []); + }, + ); + }); + }, + ); + }, +); diff --git a/packages/binden/test/observers.test.ts b/packages/binden/test/observers.test.ts new file mode 100644 index 00000000..b48b6525 --- /dev/null +++ b/packages/binden/test/observers.test.ts @@ -0,0 +1,484 @@ +import { + Binden, + BindenResponse, + type Context, + type IBindenOptions, + shutdown, +} from "../index.js"; +import { EventEmitter, errorMonitor } from "node:events"; +import { + type ICaptured, + type IListening, + capture, + listen, + never, + pollUntil, + send, + sleep, + within, +} from "./helpers.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { kListen, kOwnership, kUnlisten, owners } from "../src/seal.js"; +import { suite, test } from "node:test"; +import { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; + +/** A response without a connection: nothing is actually sent */ +function detached(): BindenResponse { + return new BindenResponse(new IncomingMessage(new Socket())); +} + +/** 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 lines of the access log */ +function completed(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === "The request has completed") + .map(({ status }) => status); +} + +suite("The observers of the framework on a response", () => { + test("run after the listeners, once by default, as their work, out of the listener table", () => { + const response = detached(); + const owner = { name: "a work" }; + const calls: unknown[] = []; + const record = (name: string) => + function recorded(this: unknown, ...args: unknown[]): void { + calls.push([ + name, + this === response, + owners.getStore() ?? null, + ...args, + ]); + }; + owners.run(owner, () => { + response[kListen]("x", record("once")); + }); + const always = record("always"); + const dispose = response[kListen]("x", always, { once: false }); + response.on("x", record("listener")); + ok(response.emit("x", 1)); + deepStrictEqual(calls.splice(0), [ + ["listener", true, null, 1], + ["once", true, owner, 1], + ["always", true, null, 1], + ]); + // Not in the listener table: the application does not remove them + deepStrictEqual(response.listenerCount("x"), 1); + response.removeAllListeners("x"); + response.removeListener("x", always); + response.removeAllListeners(); + ok(response.emit("x", 2)); + deepStrictEqual(calls.splice(0), [["always", true, null, 2]]); + dispose(); + // Removed already: nothing + dispose(); + ok(!response.emit("x", 3)); + deepStrictEqual(calls, []); + }); + + test("are removed by `kUnlisten` (the last one of the listener)", () => { + const response = detached(); + const calls: string[] = []; + const observer = (): void => { + calls.push("observer"); + }; + response[kListen]("y", observer, { once: false }); + response[kListen]("y", observer, { once: false }); + response[kListen]("y", () => { + calls.push("other"); + }); + response[kUnlisten]("y", observer); + // Neither an observer of the event, nor of another event + response[kUnlisten]("y", () => null); + response[kUnlisten]("z", observer); + response.emit("y"); + deepStrictEqual(calls.splice(0), ["observer", "other"]); + response[kUnlisten]("y", observer); + response.emit("y"); + deepStrictEqual(calls, []); + }); + + test("once each, also when an observer emits its event again", () => { + const response = detached(); + const calls: string[] = []; + response[kListen]("w", () => { + calls.push("first"); + response.emit("w"); + }); + response[kListen]("w", () => { + calls.push("second"); + }); + response.emit("w"); + response.emit("w"); + deepStrictEqual(calls, ["first", "second"]); + }); + + test("all run when one throws, which is thrown then; a throwing listener is thrown once they have run", () => { + const response = detached(); + const logged: unknown[] = []; + response[kOwnership]({ + fail: () => false, + guards: () => false, + replier: () => null, + native: () => false, + debug: (message, error) => { + logged.push([message, (error as Error).message]); + }, + warn: () => null, + note: () => null, + }); + const calls: string[] = []; + const thrown = new Error("An observer has failed"); + response[kListen]("t", () => { + throw thrown; + }); + response[kListen]("t", () => { + calls.push("after"); + }); + throws( + () => response.emit("t"), + (error: unknown) => error === thrown, + ); + deepStrictEqual(calls.splice(0), ["after"]); + const listener = new Error("A listener has failed"); + response.on("u", () => { + throw listener; + }); + response[kListen]("u", () => { + calls.push("observer"); + }); + response[kListen]("u", () => { + throw thrown; + }); + throws( + () => response.emit("u"), + (error: unknown) => error === listener, + ); + deepStrictEqual(calls, ["observer"]); + deepStrictEqual(logged, [ + ["An observer of the response has failed", "An observer has failed"], + ]); + }); + + test("an `error` that they observe is not thrown without a listener (as when they were listeners)", () => { + const response = detached(); + const seen: unknown[] = []; + response.on(errorMonitor, (error: Error) => { + seen.push(["monitor", error.message]); + }); + response[kListen]("error", (error: Error) => { + seen.push(["observer", error.message]); + }); + ok(response.emit("error", new Error("first"))); + // Observed once: without a listener, thrown as by Node.js + throws(() => response.emit("error", new Error("second")), { + message: "second", + }); + response.on("error", (error: Error) => { + seen.push(["listener", error.message]); + }); + response[kListen]("error", (error: Error) => { + seen.push(["observer", error.message]); + }); + ok(response.emit("error", new Error("third"))); + deepStrictEqual(seen, [ + ["monitor", "first"], + ["observer", "first"], + ["monitor", "second"], + ["monitor", "third"], + ["listener", "third"], + ["observer", "third"], + ]); + // Of every emitter + ok(response instanceof EventEmitter); + }); + + suite( + "an `errorMonitor` listener that throws, without an `error` listener", + () => { + test("`send()` of a stream still rejects with the error, then the monitor is thrown", async () => { + const response = detached(); + const source = new Readable({ + read(): void { + // Pushes nothing + }, + }); + const sent = response.send(source).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ); + const monitor = new Error("The monitor has failed"); + response.on(errorMonitor, () => { + throw monitor; + }); + throws( + () => response.emit("error", new Error("The send has failed")), + (error: unknown) => error === monitor, + ); + deepStrictEqual(await within(sent), "The send has failed"); + }); + + test("`flush()` still rejects with the error, then the monitor is thrown", async () => { + const response = detached(); + const flushed = response.flush().then( + () => "resolved", + (error: unknown) => (error as Error).message, + ); + const monitor = new Error("The monitor has failed"); + response.on(errorMonitor, () => { + throw monitor; + }); + throws( + () => response.emit("error", new Error("The flush has failed")), + (error: unknown) => error === monitor, + ); + deepStrictEqual(await within(flushed), "The flush has failed"); + }); + + test("an observer that fails as well is logged: the monitor is thrown", () => { + const response = detached(); + const logged: unknown[] = []; + response[kOwnership]({ + fail: () => false, + guards: () => false, + replier: () => null, + native: () => false, + debug: (message, error) => { + logged.push([message, (error as Error).message]); + }, + warn: () => null, + note: () => null, + }); + response[kListen]("error", () => { + throw new Error("An observer has failed"); + }); + const monitor = new Error("The monitor has failed"); + response.on(errorMonitor, () => { + throw monitor; + }); + throws( + () => response.emit("error", new Error("An error")), + (error: unknown) => error === monitor, + ); + deepStrictEqual(logged, [ + ["An observer of the response has failed", "An observer has failed"], + ]); + }); + + test("without an observer, the error is thrown as by Node.js", () => { + const response = detached(); + const seen: unknown[] = []; + response.on(errorMonitor, (error: Error) => { + seen.push(error.message); + }); + throws(() => response.emit("error", new Error("unhandled")), { + message: "unhandled", + }); + deepStrictEqual(seen, ["unhandled"]); + }); + }, + ); + + for (const removed of ["close", "every event"]) { + test(`the application removes the listeners of ${removed}: the cleanups, the access log and the shutdown still run`, async () => { + const cleaned = Promise.withResolvers(); + await withApp( + { access_log: true }, + (app) => { + app.use(async (context: Context) => { + context.onCleanup(() => { + cleaned.resolve(null); + }); + const { response } = context; + response.on("close", () => null); + if (removed === "close") { + response.removeAllListeners("close"); + } else { + // Also those of Node.js: its `close` comes once the client has + // closed the connection (as without binden) + response.removeAllListeners(); + } + await context.text("ok"); + }); + }, + async ({ port, server }, captured) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [200, "ok"]); + await within(cleaned.promise); + await captured.waitFor("The request has completed"); + deepStrictEqual(completed(captured), [200]); + // Its exchange is no longer waited for + await within(shutdown(server, { timeout: 50, cleanup_timeout: 50 })); + }, + ); + }); + } + + test("the application removes the listeners of `finish`: a send still settles", async () => { + const outcome = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const sent = context.response.send("sent"); + context.response.removeAllListeners("finish"); + await sent.then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve((error as { code?: string }).code); + }, + ); + await never(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [200, "sent"]); + deepStrictEqual(await within(outcome.promise), "resolved"); + }, + ); + }); + + test("the application removes the listeners of `drain`: a stream is still sent with backpressure", async () => { + const size = 64 * 1024; + const count = 64; + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const timer = setInterval(() => { + context.response.removeAllListeners("drain"); + }, 1); + // Also when the send never settles: once the connection closes + context.response.once("close", () => { + clearInterval(timer); + }); + try { + await context.send( + Readable.from( + Array.from({ length: count }, () => Buffer.alloc(size, "a")), + ), + ); + } finally { + clearInterval(timer); + } + }); + }, + async ({ port }) => { + const reply = await send(port, { ms: 10_000 }); + deepStrictEqual([reply.status, reply.body.length], [200, size * count]); + }, + ); + }); + + test("an `emit()` wrapped by a middleware (an instrumentation): they run once", async () => { + let cleaned = 0; + await withApp( + { access_log: true }, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + const emit = Reflect.get(response, "emit") as ( + ...args: unknown[] + ) => boolean; + response.emit = function wrapper( + this: BindenResponse, + ...args: unknown[] + ): boolean { + return Reflect.apply(emit, this, args); + } as never; + context.onCleanup(() => { + cleaned += 1; + }); + await context.text("ok"); + }); + }, + async ({ port }, captured) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [200, "ok"]); + await captured.waitFor("The request has completed"); + await sleep(20); + deepStrictEqual([cleaned, completed(captured)], [1, [200]]); + }, + ); + }); + + test("a settled `flush()` or send leaves no observer: an `error` without a listener is thrown", async () => { + const thrown: unknown[] = []; + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + const check = (): void => { + try { + response.emit("error", new Error("Unobserved")); + thrown.push(null); + } catch (error: unknown) { + thrown.push((error as Error).message); + } + }; + await response.flush(); + check(); + await response.send("sent"); + check(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [200, "sent"]); + deepStrictEqual(thrown, ["Unobserved", "Unobserved"]); + }, + ); + }); + + test("an abandoned work sees its response closed early: they run at the real close only", async () => { + const events: string[] = []; + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + await sleep(20); + events.push("reply"); + await context.status(503).send("deadline"); + }, + }, + (app) => { + app.use(async (context: Context) => { + context.response.on("close", () => { + events.push("early close"); + }); + context.onCleanup(() => { + events.push("cleanup"); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await send(port); + deepStrictEqual([reply.status, reply.body], [503, "deadline"]); + await pollUntil(() => events.length >= 3).catch(() => null); + deepStrictEqual(events, ["early close", "reply", "cleanup"]); + }, + ); + }); +}); diff --git a/packages/binden/test/options.test.ts b/packages/binden/test/options.test.ts new file mode 100644 index 00000000..cc459112 --- /dev/null +++ b/packages/binden/test/options.test.ts @@ -0,0 +1,325 @@ +import { + Binden, + type Context, + type IBindenOptions, + shutdown, +} from "../index.js"; +import { + type ICaptured, + type IListening, + capture, + listen, + never, + pollUntil, + send, + sleep, + within, +} from "./helpers.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { connect } from "node:net"; +import { once } from "node:events"; + +/** 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: ["127.0.0.1"], + 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, "127.0.0.1"); + 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 within( + once(socket.setEncoding("utf8"), "data"), + )) as [string]; + ok(chunk.startsWith("HTTP/1.1 400"), chunk); + await within(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); + }, + ); + }); +}); + +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, "127.0.0.1"); + 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"); + await pollUntil(() => received.includes("Content-Length: 1")); + 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 within(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"); + }, + ); + }); +}); diff --git a/packages/binden/test/own-adapter-errors.test.ts b/packages/binden/test/own-adapter-errors.test.ts new file mode 100644 index 00000000..e8d87c33 --- /dev/null +++ b/packages/binden/test/own-adapter-errors.test.ts @@ -0,0 +1,221 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { EventEmitter, once } from "node:events"; +import { HOST, capture, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { + type IStreamLikeMethods, + emitterMethods, + guard, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { connect } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; + +/** A stream-like source of its own, which records the errors that it is destroyed with */ +class Source extends EventEmitter { + public readonly destroys: unknown[] = []; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: Error): this { + this.destroys.push(error); + return this; + } +} + +/** A value whose `stack` getter throws, which Node.js cannot take as an error */ +function unstackable(): object { + return Object.defineProperty({}, "stack", { + get(): never { + throw new Error("No stack"); + }, + }); +} + +/** + * The ways a stream-like source fails with an error that binden creates for + * it: a chunk that is not bytes, a close before its end, and code of its own + * that throws a value that Node.js cannot take as an error (wrapped) + */ +const failures: readonly (readonly [string, (source: Source) => void])[] = [ + [ + "a chunk that is not bytes (TypeError)", + (source): void => { + source.emit("data", 42); + }, + ], + [ + "a close before its end (premature close)", + (source): void => { + source.emit("close"); + }, + ], + [ + "code of its own that throws a value without a stack (wrapped)", + (source): void => { + Object.assign(source, { + on(event: string, listener: () => void): Source { + if (event === "end") { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- a value that is not an error, as code of a source may throw + throw unstackable(); + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + }); + }, + ], +]; + +/** + * Runs `run` while `Error.prepareStackTrace` records each error whose stack + * V8 formats (once it is first read) + */ +function recording(run: (formatted: WeakSet) => T): T { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): unknown => { + formatted.add(error); + return typeof previous === "function" + ? Reflect.apply(previous, Error, [error, frames]) + : [`${error.name}: ${error.message}`, ...frames.map(String)].join( + "\n at ", + ); + }, + ); + try { + return run(formatted); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +suite("An error that binden creates for a stream-like source (R3.22-2)", () => { + for (const [name, fail] of failures) { + test(`${name}: formatted once created (the carrier of a value: made with its stack as a value, which holds no frames), and decided for every source, whatever stream emits it`, async () => { + const reported: unknown[] = []; + const source = new Source(); + const before = name.startsWith("code"); + if (before) { + fail(source); + } + const [readable, formatted] = recording((recorded) => { + const read = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + (error) => reported.push(error), + ).on("error", () => null); + if (!before) { + fail(source); + } + const errored = read.errored as Error; + // The carrier (see `carrierOf()`) is never formatted: its stack is + // a value from the start + return [ + read, + before + ? !recorded.has(errored) && + "value" in + Object(Reflect.getOwnPropertyDescriptor(errored, "stack")) + : recorded.has(errored), + ]; + }); + const failure = readable.errored as Error; + // Another stream that the application destroys with it, and the + // source itself + const other = new EventEmitter(); + guard(other, emitterMethods, (error) => reported.push(error)); + other.emit("error", failure); + source.emit("error", failure); + await ioTurn(); + deepStrictEqual( + [failure instanceof Error, formatted, reported], + [true, true, []], + ); + }); + } +}); + +/** The message of each late error logged so far */ +function lateOf(records: readonly Record[]): unknown[] { + return records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +suite( + "A send of a stream-like source that fails with an error of binden, whose application destroys another stream with it", + () => { + // Not for what code of the source throws: the send rejects with that + // value itself, an object of the application (see + // `stream-like-safe-stacks.test.ts`), not with the error of binden that + // carries it + for (const [name, fail] of failures.filter( + ([named]) => !named.startsWith("code"), + )) { + test(`${name}: the send rejects with it, which is not logged for the other stream (R3.22-2)`, async () => { + const captured = capture(); + let failure: unknown = null; + let second: Readable | null = null; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + if (context.url.pathname === "/second") { + // Its destroy fails later with the error of the first send + second = new Readable({ + read(): void { + // Never read + }, + destroy(_error, callback): void { + setTimeout(callback, 10, failure); + }, + }); + await context.send(second); + return; + } + const source = new Source(); + setImmediate(() => { + fail(source); + }); + await context.send(source).catch((error: unknown) => { + failure = error; + }); + await context.response.status(500).send("failed"); + }); + const listening = await listen(app); + try { + for (const line of ["GET /first", "HEAD /second"]) { + const socket = connect(listening.port, HOST); + socket.resume().on("error", () => null); + await once(socket, "connect"); + socket.write( + `${line} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`, + ); + await within(once(socket, "close")); + } + await pollUntil(() => second?.closed === true); + await ioTurn(); + deepStrictEqual( + [failure instanceof Error, lateOf(captured.records)], + [true, []], + ); + } finally { + await listening.close(); + } + }); + } + }, +); diff --git a/packages/binden/test/own-failures.test.ts b/packages/binden/test/own-failures.test.ts new file mode 100644 index 00000000..25387b66 --- /dev/null +++ b/packages/binden/test/own-failures.test.ts @@ -0,0 +1,270 @@ +import { + Binden, + type Context, + type IStreamLike, + releasedFailure, +} from "../index.js"; +import { type ICaptured, capture, listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** The code (or the message) of an error */ +function codeOf(error: unknown): unknown { + const { code, message } = error as { code?: unknown; message?: unknown }; + return code ?? message; +} + +/** + * Replies one request with the middleware, which sends the source (with a + * `Content-Length` of 5 bytes when `length`): resolves with the status that + * the client gets within 2 s, how the send has settled, and its logger, + * once the middleware has settled + */ +async function replied( + source: Readable, + length: boolean, +): Promise<[number, unknown, ICaptured]> { + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + if (length) { + context.response.setHeader("Content-Length", 5); + } + await context.send(source).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve(codeOf(error)); + throw error; + }, + ); + }, + ); + const listening = await listen(app); + try { + const { status } = await send(listening.port, { ms: 2000 }); + return [status, await within(outcome.promise), captured]; + } finally { + await listening.close(); + } +} + +/** A source of one chunk (an object in `objectMode`), whose `_destroy()` calls `destroyed` */ +function sourceOf( + objectMode: boolean, + destroyed: ( + error: Error | null, + callback: (error?: Error | null) => void, + ) => void, +): Readable { + return new Readable({ + objectMode, + read(): void { + this.push(objectMode ? { a: 1 } : "x".repeat(10)); + this.push(null); + }, + destroy: destroyed, + }); +} + +suite("A send of a Readable that binden fails itself", () => { + for (const [what, objectMode, error] of [ + [ + "a body longer than its `Content-Length`", + false, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + ["a chunk that is not bytes", true, "ERR_INVALID_ARG_TYPE"], + ] as const) { + test(`(${what}) settles at once with its error, which is replied, also when the \`_destroy()\` of the source swallows it (it never emits its \`error\`)`, async () => { + const source = sourceOf(objectMode, (_error, callback) => { + callback(null); + }); + const [status, outcome] = await replied(source, !objectMode); + // Destroyed with it afterwards + deepStrictEqual( + [status, outcome, source.destroyed, codeOf(source.errored)], + [500, error, true, error], + ); + }); + } + + test("settles at once with its error, which is replied, while the `_destroy()` of the source completes later: its guard does not log that error again", async () => { + let complete = (): void => { + // Until the source is destroyed + }; + const source = sourceOf(false, (error, callback) => { + complete = (): void => { + callback(error); + }; + }); + const [status, outcome, captured] = await replied(source, true); + deepStrictEqual( + [status, outcome, source.destroyed, source.closed], + [500, "ERR_HTTP_CONTENT_LENGTH_MISMATCH", true, false], + ); + const emitted = Promise.withResolvers(); + // Once its guard is removed (on the turn after its close) + const closed = new Promise((resolve) => { + source.on("close", () => { + setImmediate(resolve); + }); + }); + source.on("error", emitted.resolve); + complete(); + deepStrictEqual( + codeOf(await within(emitted.promise)), + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ); + await within(closed); + deepStrictEqual( + [ + captured.records.filter(({ msg }) => msg === releasedFailure), + source.listenerCount("error"), + ], + [[], 1], + ); + }); + + test("a later error of a source that it has destroyed with its own error is logged by its guard", async () => { + let fail = (): void => { + // Until the source is destroyed + }; + const late = Object.assign(new Error("Failed later"), { code: "EIO" }); + const source = sourceOf(false, (_error, callback) => { + fail = (): void => { + callback(late); + }; + }); + const [status, outcome, captured] = await replied(source, true); + deepStrictEqual( + [status, outcome], + [500, "ERR_HTTP_CONTENT_LENGTH_MISMATCH"], + ); + fail(); + const record = await captured.waitFor(releasedFailure); + deepStrictEqual([record.level, codeOf(record.failure)], [20, "EIO"]); + }); + + test("whose own `destroy()` throws: settles at once with its error, which is replied, and the failure of the release is logged (`debug`)", async () => { + const failure = new Error("Not destroyed"); + const source = new (class extends Readable { + // With an error only (`autoDestroy` destroys it without one) + public override destroy(error?: Error): this { + if (error) { + throw failure; + } + return super.destroy(); + } + })({ + read(): void { + this.push("x".repeat(10)); + this.push(null); + }, + }); + const [status, outcome, captured] = await replied(source, true); + const released = captured.records.filter( + ({ msg }) => msg === "The release of a stream has failed", + ); + deepStrictEqual( + [status, outcome, released.map(({ level }) => level)], + [500, "ERR_HTTP_CONTENT_LENGTH_MISMATCH", [20]], + ); + }); +}); + +suite("A send of a stream-like source that binden fails itself", () => { + test("settles at once with its error, which is replied: no guard logs that error again when the source emits it once destroyed", async () => { + const source = new EventEmitter(); + let resumed = false; + Object.assign(source, { + pause(): void { + // Its data comes once + }, + resume(): void { + if (!resumed) { + resumed = true; + queueMicrotask(() => { + source.emit("data", Buffer.from("x".repeat(10))); + }); + } + }, + destroy(error?: Error): void { + setImmediate(() => { + source.emit("error", error); + source.emit("close"); + }); + }, + }); + const captured = capture(); + const outcome = Promise.withResolvers(); + const emitted = Promise.withResolvers(); + source.on("error", emitted.resolve); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + context.response.setHeader("Content-Length", 5); + await context.send(source as unknown as IStreamLike).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve(codeOf(error)); + throw error; + }, + ); + }, + ); + const listening = await listen(app); + try { + const { status } = await send(listening.port, { ms: 2000 }); + deepStrictEqual( + [ + status, + await within(outcome.promise), + codeOf(await within(emitted.promise)), + ], + [ + 500, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + ); + // Once the guards are removed (on the turn after its close) + await new Promise((resolve) => { + setImmediate(resolve); + }); + deepStrictEqual( + captured.records.filter(({ msg }) => msg === releasedFailure), + [], + ); + } finally { + await listening.close(); + } + }); +}); + +suite( + "A send of a Readable that the application destroys with an error that its `_destroy()` swallows (it emits its `close` only)", + () => { + test("before the headers are committed: rejects with that error, which is replied", async () => { + const failure = new Error("Swallowed"); + const source = new Readable({ + read(): void { + this.destroy(failure); + }, + destroy(_error, callback): void { + callback(null); + }, + }); + const [status, outcome] = await replied(source, false); + deepStrictEqual( + [status, outcome, source.closed, source.errored], + [500, "Swallowed", true, failure], + ); + }); + }, +); diff --git a/packages/binden/test/ownership.test.ts b/packages/binden/test/ownership.test.ts new file mode 100644 index 00000000..dc4451df --- /dev/null +++ b/packages/binden/test/ownership.test.ts @@ -0,0 +1,1740 @@ +/* eslint-disable init-declarations */ +import { + Binden, + BindenAbortError, + BindenError, + BindenResponse, + type Context, + Cookie, + type IBindenOptions, + type IResponseOperation, + kOperation, +} from "../index.js"; +import { + type ICaptured, + type IListening, + capture, + listen, + never, + pollUntil, + sleep, + within, +} from "./helpers.js"; +import { + type IncomingHttpHeaders, + IncomingMessage, + type OutgoingHttpHeaders, + ServerResponse, + request, +} from "node:http"; +import { PassThrough, Readable } from "node:stream"; +import { Socket, connect } from "node:net"; +import { after, afterEach, before, mock, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import fsp, { + type FileHandle, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { kCancel, kFramework, kSeal, owners } from "../src/seal.js"; +import { join } from "node:path"; +import { listen as listenPlain } from "./http.js"; +import { once } from "node:events"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; + +/** A response as the client receives it, with its informational responses */ +interface IReceived { + readonly status: number; + readonly message: string; + readonly headers: IncomingHttpHeaders; + readonly trailers: NodeJS.Dict; + readonly body: string; + readonly informational: number[]; +} + +/** Sends a request (on a new connection), and reads its response within 5 s */ +function fetch( + port: number, + headers: OutgoingHttpHeaders = {}, +): Promise { + return new Promise((resolve, reject) => { + const informational: number[] = []; + request({ + agent: false, + headers, + host: "127.0.0.1", + port, + signal: AbortSignal.timeout(10_000), + }) + .on("information", ({ statusCode }) => { + informational.push(statusCode); + }) + .on("response", (response) => { + let body = ""; + response + .setEncoding("utf8") + .on("data", (chunk: string) => { + body += chunk; + }) + .once("end", () => { + resolve({ + status: response.statusCode ?? 0, + message: response.statusMessage ?? "", + headers: response.headers, + trailers: response.trailers, + body, + informational, + }); + }) + .once("error", reject); + }) + .once("error", reject) + .end(); + }); +} + +/** + * Sends a request on a connection that is not read for `pause` milliseconds + * (a slow client), then reads everything until the server closes it + */ +async function slowly(port: number, pause: number): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + socket.pause(); + await sleep(pause); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + const closed = once(socket, "close"); + socket.resume(); + await within(closed, 10_000); + return Buffer.concat(chunks); +} + +/** + * Sends raw bytes on a new connection, and reads the head of the response + * (the connection is closed once it has come) + */ +async function headOf(port: number, data: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let received = ""; + const { promise, resolve } = Promise.withResolvers(); + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + const end = received.indexOf("\r\n\r\n"); + if (end !== -1) { + resolve(received.slice(0, end)); + } + }); + socket.write(data); + try { + return await within(promise); + } finally { + socket.destroy(); + } +} + +/** 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(); + } +} + +/** A promise with its `resolve()` */ +function gate(): { promise: Promise; open(): void } { + const { promise, resolve } = Promise.withResolvers(); + return { promise, open: resolve as () => void }; +} + +/** A source that never ends (nor pushes anything by itself) */ +function silent(): Readable { + return new Readable({ + read(): void { + // Nothing + }, + }); +} + +/** The reason of the rejection of a promise (`null` once it resolves) */ +function reasonOf(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + +suite("Ownership of the response once the exchange is sealed", () => { + test("every mutation of the abandoned work is ignored once the deadline has passed", async () => { + const abandoned = gate(); + const calls: string[] = []; + let synchronous = 0; + let accepted: unknown = null; + const threw: unknown[] = []; + const handlerCookie = new Cookie({ key: "handler", value: "1" }); + const callback = + (name: string) => + (...args: unknown[]): void => { + calls.push(`${name}:${args.length}`); + }; + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + context.response + .status(503) + .setHeader("X-Handler", "1") + .cookies.add(handlerCookie); + // The abandoned work tries to reply in the meantime + await abandoned.promise; + // Chunked, with its trailers (and headers that are set only now) + context.response.setHeader("Content-Type", "text/plain"); + context.response.write("hand"); + context.response.end("ler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + // Captured before the deadline + const { cookies } = response; + await sleep(60); + // Not even validated + for (const invalid of [ + (): unknown => response.status(99), + (): unknown => response.type("invalid"), + (): unknown => response.vary("a b"), + (): unknown => response.attachment(""), + (): unknown => response.clearCookie("a b"), + (): unknown => response.setHeaders(null as never), + ]) { + try { + invalid(); + } catch (error: unknown) { + threw.push(error); + } + } + response.setHeader("X-Late", "1"); + response.appendHeader("X-Handler", "late"); + response.removeHeader("X-Handler"); + response.setHeaders(new Map([["X-Late-Map", "1"]])); + response.statusCode = 200; + response.statusMessage = "Late"; + response + .status(201) + .set({ "X-Set": "1" }) + .type("text/html") + .vary("X-Late") + .attachment("late.txt") + .clearCookie("handler"); + cookies.add(new Cookie({ key: "late", value: "1" })); + cookies.delete(handlerCookie); + cookies.clear(); + response.writeContinue(callback("continue")); + response.writeProcessing(callback("processing")); + response.writeEarlyHints( + { link: "; rel=preload" }, + callback("hints"), + ); + response.addTrailers({ "X-Trailer": "late" }); + response.flushHeaders(); + response.writeHead(200, { "X-Head": "late" }); + // The alias of Node.js 24 as well + response.writeHeader(200, { "X-Header": "late" }); + accepted = response.write("late", callback("write")); + response.write("late", "utf8", callback("write-encoding")); + response.end("late", callback("end")); + response.end(callback("end-again")); + response.destroy(); + synchronous = calls.length; + await context.send("late"); + await context.response.json({ late: true }); + await sleep(10); + abandoned.open(); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual( + [received.status, received.message, received.body], + [503, "Service Unavailable", "handler"], + ); + deepStrictEqual(received.informational, []); + deepStrictEqual(received.headers["x-handler"], "1"); + deepStrictEqual(received.headers["set-cookie"], [ + handlerCookie.toString(), + ]); + deepStrictEqual(received.headers["content-type"], "text/plain"); + deepStrictEqual(received.trailers, {}); + for (const name of [ + "x-late", + "x-late-map", + "x-set", + "x-head", + "x-header", + "vary", + "content-disposition", + "trailer", + ]) { + ok(!(name in received.headers), name); + } + }, + ); + deepStrictEqual(threw, []); + // Called once each, asynchronously, without an error + deepStrictEqual(synchronous, 0); + deepStrictEqual(accepted, true); + deepStrictEqual(calls.toSorted(), [ + "continue:0", + "end-again:0", + "end:0", + "hints:0", + "processing:0", + "write-encoding:0", + "write:0", + ]); + }); + + test( + "without writeInformation() (before Node.js 24.18.0), it is ignored or throws", + { + skip: + typeof (ServerResponse.prototype as { writeInformation?: unknown }) + .writeInformation === "function", + }, + async () => { + const abandoned = gate(); + const calls: string[] = []; + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + await abandoned.promise; + try { + context.response.writeInformation(110, {}); + } catch (error: unknown) { + calls.push(error instanceof TypeError ? "TypeError" : "other"); + } + await context.status(503).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(60); + // Ignored, as Node.js would not have it either: no TypeError + context.response.writeInformation(102, {}, () => { + calls.push("ignored"); + }); + abandoned.open(); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual(received.body, "handler"); + }, + ); + deepStrictEqual(calls.toSorted(), ["TypeError", "ignored"]); + }, + ); + + test( + "an informational response of the abandoned work is ignored", + // From Node.js 24.18.0 + { + skip: + typeof (ServerResponse.prototype as { writeInformation?: unknown }) + .writeInformation !== "function", + }, + async () => { + const abandoned = gate(); + const calls: number[] = []; + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + await abandoned.promise; + context.response.writeInformation(110, {}, () => { + calls.push(110); + }); + await context.status(503).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(60); + context.response.writeInformation(102, {}, () => { + calls.push(102); + }); + context.response.writeInformation(103, { link: "" }); + abandoned.open(); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual(received.informational, [110]); + deepStrictEqual(received.body, "handler"); + }, + ); + deepStrictEqual( + calls.toSorted((a, b) => a - b), + [102, 110], + ); + }, + ); + + test("the reply of a deadline closes the connection of a body that has not been read", async () => { + await withApp( + { timeout: 30 }, + (app) => { + app.use(async () => { + // Does not read the body + await never(); + }); + }, + async ({ port }) => { + // The rest of the body is never sent + const unread = await headOf( + port, + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 1000\r\n\r\npart", + ); + ok(unread.startsWith("HTTP/1.1 503 "), unread); + ok(/^Connection: close$/imu.test(unread), unread); + // Without a body, the connection is kept + const read = await headOf(port, "GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + ok(read.startsWith("HTTP/1.1 503 "), read); + ok(/^Connection: keep-alive$/imu.test(read), read); + }, + ); + }); + + test("the abandoned work changes neither the connection nor the headers of the reply", async () => { + const abandoned = gate(); + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + await abandoned.promise; + // Long enough for a timeout of the socket of 1 ms + await sleep(30); + await context.status(503).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + // Arrays of values, kept by the work before the deadline + const set = ["a"]; + response.setHeader("X-Set", set); + response.setHeader("X-Got", ["a"]); + const got = response.getHeader("X-Got") as string[]; + response.setHeader("X-All", ["a"]); + const all = response.getHeaders()["x-all"] as string[]; + const appended = ["a"]; + response.appendHeader("X-Appended", appended); + const mapped = ["a"]; + response.setHeaders(new Map([["X-Mapped", mapped]])); + await sleep(60); + for (const values of [set, got, all, appended, mapped]) { + values.push("late"); + } + response.sendDate = false; + response.shouldKeepAlive = false; + response.setTimeout(1); + abandoned.open(); + }); + }, + async ({ port }) => { + const head = await headOf(port, "GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + ok(head.startsWith("HTTP/1.1 503 "), head); + ok(/^Date: /mu.test(head), head); + ok(/^Connection: keep-alive$/imu.test(head), head); + for (const name of [ + "X-Set", + "X-Got", + "X-All", + "X-Appended", + "X-Mapped", + ]) { + ok(head.includes(`\r\n${name}: a\r\n`), head); + } + ok(!head.includes("late"), head); + }, + ); + }); + + test("a cancelled source is released without the rights of the framework", async () => { + const released = gate(); + await withApp( + { timeout: 20 }, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + const source = new Readable({ + read(): void { + // Its first chunk never comes + }, + destroy(error, callback): void { + // Code of the application, run by the cancellation + response.setHeader("X-Released", "1"); + response.statusCode = 200; + released.open(); + callback(error); + }, + }); + await reasonOf(context.send(source)); + await never(); + }); + }, + async ({ port }) => { + const head = await headOf(port, "GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + await within(released.promise); + ok(head.startsWith("HTTP/1.1 503 "), head); + ok(!/^X-Released/imu.test(head), head); + }, + ); + }); + + test("the default error reply closes the connection of a body that has not been read in full", async () => { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + if (context.request.method === "POST") { + // A part of the body, then a failure + await once(context.request, "data"); + } + throw new BindenError(422); + }); + }, + async ({ port }) => { + // The rest of the body is never sent: Node.js would wait for it + // before it reads the next request of the connection + const unread = await headOf( + port, + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 1000\r\n\r\npart", + ); + ok(unread.startsWith("HTTP/1.1 422 "), unread); + ok(/^Connection: close$/imu.test(unread), unread); + const read = await headOf(port, "GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + ok(read.startsWith("HTTP/1.1 422 "), read); + ok(/^Connection: keep-alive$/imu.test(read), read); + }, + ); + }); + + test("the error replies have the reason phrase of their status", async () => { + await withApp( + { + error_handler: (context: Context): void => { + if (context.url.pathname === "/failed-handler") { + // Refused by Node.js: the opaque `500` does not keep it + context.response.statusMessage = "a\nb"; + throw new Error("Failed"); + } + }, + }, + (app) => { + app.use((context: Context) => { + context.response.status(201).statusMessage = "Made"; + throw new BindenError(422); + }); + }, + async ({ port }) => { + for (const [path, line] of [ + ["/", "HTTP/1.1 422 Unprocessable Entity"], + ["/failed-handler", "HTTP/1.1 500 Internal Server Error"], + ]) { + const head = await headOf( + port, + `GET ${path} HTTP/1.1\r\nHost: a\r\n\r\n`, + ); + deepStrictEqual(head.split("\r\n")[0], line); + } + }, + ); + }); + + test("the reply of a timed out error handler closes the connection of a body that has not been read", async () => { + await withApp( + { + error_handler_timeout: 20, + error_handler: async (): Promise => { + await never(); + }, + }, + (app) => { + app.use(() => { + throw new Error("Failed"); + }); + }, + async ({ port }) => { + const unread = await headOf( + port, + "POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 1000\r\n\r\npart", + ); + ok(unread.startsWith("HTTP/1.1 500 "), unread); + ok(/^Connection: close$/imu.test(unread), unread); + const read = await headOf(port, "GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + ok(read.startsWith("HTTP/1.1 500 "), read); + ok(/^Connection: keep-alive$/imu.test(read), read); + }, + ); + }); + + test("a stream piped by the abandoned work does not start the response", async () => { + const abandoned = gate(); + await withApp( + { + timeout: 20, + error_handler: async (): Promise => { + // It does not reply: the default reply is sent + await abandoned.promise; + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(60); + silent().pipe(context.response); + abandoned.open(); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [503, ""]); + }, + ); + }); + + test( + "a timed out error handler that has ended its reply: it is still sent, and its late mutations are ignored", + { + // Windows takes a whole write while its send buffer has room: the + // response may finish before the client reads it + skip: process.platform === "win32", + }, + async () => { + const size = 8 * 1024 * 1024; + const states: boolean[] = []; + await withApp( + { + error_handler_timeout: 50, + error_handler: async (context: Context): Promise => { + const { response } = context; + response.status(500); + // Not awaited: the client reads slowly + response.send(Buffer.alloc(size, "a")).catch(() => null); + states.push(response.writableEnded, response.writableFinished); + await sleep(150); + states.push(response.writableFinished); + // Once timed out + response.destroy(); + response.setHeader("X-Late", "1"); + response.statusCode = 200; + await never(); + }, + }, + (app) => { + app.use(() => { + throw new Error("Failed"); + }); + }, + async ({ port }, captured) => { + const received = await slowly(port, 400); + const index = received.indexOf("\r\n\r\n"); + const head = received.subarray(0, index).toString(); + ok(head.startsWith("HTTP/1.1 500 "), head); + deepStrictEqual(received.length - index - 4, size); + await captured.waitFor( + "The provided `error_handler` has not settled in time", + ); + }, + ); + deepStrictEqual(states, [true, false, false]); + }, + ); + + test("a deadline cancels a send whose first chunk has not come: the error handler replies at once", async () => { + const source = silent(); + let rejection: unknown = null; + let started: unknown = null; + await withApp( + { + timeout: 30, + error_handler: (context: Context): void => { + ({ started } = context.response); + // A late continuation of the stream, while the handler replies + source.emit("data", "late"); + source.emit("end"); + // Framed by Node.js: the length set for the stream is gone + context.response.statusCode = 503; + context.response.end("deadline"); + }, + }, + (app) => { + app.use(async (context: Context) => { + context.response.status(206).set({ + "Content-Length": 1000, + "Content-Range": "bytes 0-999/2000", + }); + rejection = await reasonOf(context.response.send(source)); + }); + }, + async ({ port }) => { + const received = await fetch(port); + // Chunked, as Node.js frames a body without a length + deepStrictEqual( + [ + received.status, + received.body, + received.headers["content-length"] ?? null, + received.headers["transfer-encoding"], + ], + [503, "deadline", null, "chunked"], + ); + ok(!("content-range" in received.headers)); + }, + ); + deepStrictEqual(started, false); + ok(source.destroyed); + ok(rejection instanceof BindenAbortError); + deepStrictEqual(rejection.reason, "deadline"); + }); + + test("a deadline once a send has committed its headers closes the connection", async () => { + await withApp( + { timeout: 30 }, + (app) => { + app.use(async (context: Context) => { + const source = silent(); + source.push("first"); + await context.response.send(source); + }); + }, + async ({ port }) => { + await within( + fetch(port).then( + () => { + throw new Error("Not cut"); + }, + () => null, + ), + ); + }, + ); + }); + + suite("`sendFile()` cancelled, or whose send is ignored", () => { + let tmp: string; + let file: string; + + before(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), "binden-owner-"))); + file = join(tmp, "file.txt"); + await writeFile(file, "file"); + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + afterEach(() => { + mock.restoreAll(); + syncBuiltinESMExports(); + }); + + /** The streams of the files (see `delay()`) */ + const streams: Readable[] = []; + + /** Delays `open()` (and `stat()` of the handles that it opens) until the gates open */ + const delay = ({ + opening, + stating, + reading, + }: { + opening?: Promise; + stating?: Promise; + reading?: Promise; + }): FileHandle[] => { + const handles: FileHandle[] = []; + streams.length = 0; + const { open: original } = fsp; + mock.method( + fsp, + "open", + async (...args: Parameters): Promise => { + await opening; + const handle = await original(...args); + const stat = handle.stat.bind(handle); + handle.stat = (async () => { + await stating; + return stat(); + }) as FileHandle["stat"]; + const create = handle.createReadStream.bind(handle); + handle.createReadStream = (( + ...options: Parameters + ) => { + const stream = create(...options); + streams.push(stream); + if (!reading) { + return stream; + } + // Read once the gate opens (`stream.pipe()` would start it) + const proxy = new PassThrough(); + proxy.once("close", () => { + stream.destroy(); + }); + reading + .then(() => { + stream.pipe(proxy); + }) + .catch(() => null); + return proxy; + }) as FileHandle["createReadStream"]; + handles.push(handle); + return handle; + }, + ); + syncBuiltinESMExports(); + return handles; + }; + + /** Resolves once every handle is closed */ + const closed = async (handles: FileHandle[]): Promise => { + // Not polled forever: a file that stays open fails the test + await pollUntil( + () => handles.length > 0 && handles.every(({ fd }) => fd === -1), + ).catch(() => { + throw new Error("A file is still open"); + }); + }; + + /** + * A file operation cancelled in a phase goes on while the error handler + * replies: the file is closed, and nothing of it is replied + */ + const cancelled = async (phase: "opening" | "stating"): Promise => { + const released = gate(); + const handles = delay({ [phase]: released.promise }); + let rejection: unknown = null; + await withApp( + { + timeout: 30, + error_handler: async (context: Context): Promise => { + context.response.status(503).setHeader("X-Handler", "1"); + // The file operation goes on meanwhile + released.open(); + await within(closed(handles)); + await sleep(10); + await context.send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + rejection = await reasonOf(context.response.sendFile(file)); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual( + [received.status, received.body, received.headers["x-handler"]], + [503, "handler", "1"], + ); + for (const name of [ + "etag", + "last-modified", + "accept-ranges", + "x-content-type-options", + ]) { + ok(!(name in received.headers), name); + } + }, + ); + ok(rejection instanceof BindenAbortError); + deepStrictEqual(handles.length, 1); + // Opened or stated late: it is not read (no stream of it) + deepStrictEqual(streams.length, 0); + }; + + test("while the file is opened, concurrently with the reply of the error handler", async () => { + await cancelled("opening"); + }); + + test("while the metadata of the file is read, concurrently with the reply of the error handler", async () => { + await cancelled("stating"); + }); + + test("while its stream has not delivered its first chunk: its status and its headers are removed", async () => { + const handles = delay({ reading: never() }); + let rejection: unknown = null; + await withApp( + { + timeout: 30, + error_handler: (context: Context): void => { + // The status from before the file + context.response.end("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + context.response + .setHeader("X-Before", "1") + .setHeader("Content-Type", "text/html"); + rejection = await reasonOf( + context.response.sendFile(file, { content_type: "text/plain" }), + ); + }); + }, + async ({ port }) => { + const received = await fetch(port, { Range: "bytes=0-1" }); + deepStrictEqual( + [ + received.status, + received.body, + received.headers["x-before"], + received.headers["content-type"], + ], + [200, "handler", "1", "text/html"], + ); + for (const name of [ + "etag", + "last-modified", + "accept-ranges", + "content-range", + "x-content-type-options", + ]) { + ok(!(name in received.headers), name); + } + }, + ); + ok(rejection instanceof BindenAbortError); + await within(closed(handles)); + }); + + test("the reply does not wait for the file, whose headers are removed", async () => { + const released = gate(); + const handles = delay({ stating: released.promise }); + await withApp( + { timeout: 30 }, + (app) => { + app.use(async (context: Context) => { + context.response.setHeader("ETag", '"before"'); + await context.response.sendFile(file, { + cache_control: "no-store", + }); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [503, ""]); + ok(!("cache-control" in received.headers)); + // The default reply removes the `ETag` + ok(!("etag" in received.headers)); + }, + ); + released.open(); + await within(closed(handles)); + }); + + test("while the path is resolved under `root`: the file is not opened", async () => { + const released = gate(); + const handles = delay({}); + const { realpath: original } = fsp; + // The root, then the file + const resolved: Promise[] = []; + const both = gate(); + mock.method(fsp, "realpath", (path: string): Promise => { + const real = released.promise.then(async () => original(path)); + resolved.push(real); + if (resolved.length === 2) { + both.open(); + } + return real; + }); + syncBuiltinESMExports(); + await withApp( + { timeout: 30 }, + (app) => { + app.use(async (context: Context) => { + await context.response.sendFile("file.txt", { root: tmp }); + }); + }, + async ({ port }) => { + deepStrictEqual((await fetch(port)).status, 503); + }, + ); + released.open(); + await within(both.promise); + await Promise.all(resolved); + await sleep(20); + deepStrictEqual(handles.length, 0); + }); + + test("once the response has ended while the metadata of the file is read: it resolves, and the file is closed", async () => { + const released = gate(); + const handles = delay({ stating: released.promise }); + const outcome = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const sent = reasonOf(context.response.sendFile(file)); + await pollUntil(() => handles.length > 0); + // Its send would be ignored: as `send()`, it resolves + context.response.end("other"); + released.open(); + outcome.resolve(await sent); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [200, "other"]); + }, + ); + deepStrictEqual(await within(outcome.promise), null); + await within(closed(handles)); + // Never read + deepStrictEqual(streams.length, 0); + }); + + test("on a response that has ended (by a work that keeps its rights): it resolves at once, without checking its options nor touching the filesystem", async () => { + const calls: string[] = []; + for (const name of ["open", "realpath", "lstat"] as const) { + const original = fsp[name] as (...args: unknown[]) => unknown; + mock.method(fsp, name, (...args: unknown[]): unknown => { + calls.push(name); + return original(...args); + }); + } + syncBuiltinESMExports(); + const outcomes = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + response.end("ended"); + outcomes.resolve( + await Promise.all( + [ + (): Promise => response.sendFile(file), + (): Promise => + response.sendFile("file.txt", { root: tmp }), + (): Promise => + response.sendFile("missing.txt", { root: tmp }), + (): Promise => + response.sendFile(join(tmp, "missing.txt")), + (): Promise => + response.sendFile("../file.txt", { root: tmp }), + (): Promise => response.sendFile(file, { max_age: -1 }), + ].map(async (sent) => reasonOf(sent())), + ), + ); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [200, "ended"]); + }, + ); + deepStrictEqual( + await within(outcomes.promise), + Array.from({ length: 6 }, () => null), + ); + deepStrictEqual(calls, []); + }); + + test("the stream of a send that settles without reading it is destroyed, which closes the file", async () => { + const handles = delay({}); + const outcomes: unknown[] = []; + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + // Sends that settle without reading the stream (as an ignored + // one would), then the reply + const sends = [ + (): Promise => { + // Its file fails to close (once closed): nobody waits for it + const handle = handles.at(-1) as FileHandle; + const close = handle.close.bind(handle); + handle.close = async (): Promise => { + await close(); + throw new Error("The file has failed to close"); + }; + return Promise.resolve(); + }, + async (): Promise => { + await Promise.reject(new Error("Refused")); + }, + ]; + for (const fake of sends) { + response.send = fake; + outcomes.push(await reasonOf(response.sendFile(file))); + } + Reflect.deleteProperty(response, "send"); + await context.send("reply"); + }); + }, + async ({ port }) => { + deepStrictEqual((await fetch(port)).body, "reply"); + }, + ); + deepStrictEqual( + outcomes.map((outcome) => (outcome as Error | null)?.message ?? null), + [null, "Refused"], + ); + deepStrictEqual(streams.length, 2); + await within(closed(handles)); + ok(streams.every(({ destroyed }) => destroyed)); + }); + + test("once the stack has completed", async () => { + const released = gate(); + const handles = delay({ opening: released.promise }); + await withApp( + { timeout: 30 }, + (app) => { + app.use((context: Context) => { + context.response.sendFile(file).catch(() => null); + context.done = true; + }); + }, + async ({ port }) => { + deepStrictEqual((await fetch(port)).status, 503); + }, + ); + released.open(); + await within(closed(handles)); + }); + + test("an unexpected failure once the stack has completed closes the connection", async () => { + const released = gate(); + const handles = delay({ opening: released.promise }); + await withApp( + { timeout: 30 }, + (app) => { + app.use((context: Context) => { + context.setHeader("ETag", '"tag"'); + context.response.sendFile(file).catch(() => null); + context.done = true; + // The cancellation fails: it restores the headers + context.response.removeHeader = (): never => { + throw new Error("Unexpected"); + }; + }); + }, + async ({ port }, captured) => { + await within( + fetch(port).then( + () => { + throw new Error("Not cut"); + }, + () => null, + ), + ); + await captured.waitFor("The request has failed"); + }, + ); + released.open(); + await within(closed(handles)); + }); + + /** How a send has settled: `resolved`, or `rejected` with its reason */ + const settledAs = (promise: Promise): Promise => + promise.then( + () => ["resolved"], + (reason: unknown) => ["rejected", reason], + ); + + /** + * An error handler that fails with a value while its `sendFile()` and its + * send of a stream are pending: they reject with it, and the 500 is + * replied + */ + const cancelledBy = async (failure: unknown): Promise => { + const released = gate(); + const handles = delay({ opening: released.promise }); + const settled = Promise.withResolvers(); + await withApp( + { + error_handler: async (context: Context): Promise => { + Promise.all([ + settledAs(context.response.sendFile(file)), + settledAs(context.response.send(silent())), + ]).then(settled.resolve, settled.reject); + await sleep(10); + throw failure; + }, + }, + (app) => { + app.use(() => { + throw new Error("A failure"); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [500, ""]); + }, + ); + deepStrictEqual(await within(settled.promise), [ + ["rejected", failure], + ["rejected", failure], + ]); + released.open(); + await within(closed(handles)); + }; + + for (const [name, failure] of [ + ["`null`", null], + // eslint-disable-next-line no-undefined -- a thrown `undefined` + ["`undefined`", undefined], + ["an `Error`", new Error("The error handler has failed")], + ] as const) { + test(`cancelled by an error handler that throws ${name}: it rejects with it, as its send of a stream, and the 500 is replied`, async () => { + await cancelledBy(failure); + }); + } + + test("started by an error handler in the turn of its failure: it rejects with it (never as an ignored send), and the 500 is replied", async () => { + const handles = delay({}); + const failure = new Error("The error handler has failed"); + /** + * How each `sendFile()` has settled, called once the error handler + * has thrown and a number of microtasks have run (once more for each + * request): `null` for one ignored at once (once it has failed) + */ + const outcomes: Promise[] = []; + let turns = 0; + await withApp( + { + error_handler: (context: Context): never => { + let chain: Promise = Promise.resolve(); + for (let turn = 0; turn < turns; turn += 1) { + chain = chain.then(() => null); + } + outcomes.push( + chain.then(() => { + const sent = settledAs(context.response.sendFile(file)); + return context.response.started ? sent : null; + }), + ); + throw failure; + }, + }, + (app) => { + app.use(() => { + throw new Error("A failure"); + }); + }, + async ({ port }) => { + for (; turns < 16; turns += 1) { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [500, ""]); + } + }, + ); + const settled = await within(Promise.all(outcomes)); + const started = settled.filter((outcome) => outcome !== null); + // Before it has failed, and once + ok(started.length > 0 && started.length < settled.length); + deepStrictEqual( + started, + started.map(() => ["rejected", failure]), + ); + await within(closed(handles)); + }); + + /** + * The stack leaves a `sendFile()` (whose file is read) and a send of a + * stream (whose first chunk has not come) pending, without awaiting them, + * and fails: an error handler that fails (`expired`: times out) cancels + * them, and its `500` replaces them. Their reasons. + */ + const danglingCancelledBy = async ( + failure: { error: unknown } | "expired", + ): Promise => { + const released = gate(); + const handles = delay({ stating: released.promise }); + const settled = Promise.withResolvers(); + await withApp( + { + error_handler_timeout: 50, + error_handler: async (): Promise => { + if (failure === "expired") { + await never(); + } else { + await sleep(10); + throw failure.error; + } + }, + }, + (app) => { + app.use(async (context: Context) => { + Promise.all([ + settledAs(context.response.sendFile(file)), + settledAs(context.response.send(silent())), + ]).then(settled.resolve, settled.reject); + // Once its file is read + await sleep(10); + throw new Error("The stack has failed"); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [500, ""]); + }, + ); + const outcomes = await within(settled.promise); + released.open(); + await within(closed(handles)); + return outcomes; + }; + + test("left pending by the stack, cancelled by an error handler that fails: it rejects with its failure, as the send of a stream, and the 500 is replied", async () => { + const failure = new Error("The error handler has failed"); + deepStrictEqual(await danglingCancelledBy({ error: failure }), [ + ["rejected", failure], + ["rejected", failure], + ]); + }); + + test("left pending by the stack, cancelled by an error handler that times out: it rejects with its time out, as the send of a stream, and the 500 is replied", async () => { + const outcomes = await danglingCancelledBy("expired"); + deepStrictEqual( + outcomes.map(([outcome, reason]) => { + ok(reason instanceof BindenError); + return [outcome, reason.status, reason.code]; + }), + [ + ["rejected", 500, "error_handler_timeout"], + ["rejected", 500, "error_handler_timeout"], + ], + ); + }); + + test("cancelled by a pipe of the stack that fails with `null`: it rejects with it, as its send of a stream, and the 500 is replied", async () => { + const released = gate(); + // Awaited (by `open()`) once the file is being opened + const opened = gate(); + const opening = { + then: ( + resolve: () => void, + reject: (reason: unknown) => void, + ): Promise => { + opened.open(); + return released.promise.then(resolve, reject); + }, + } as unknown as Promise; + const handles = delay({ opening }); + const settled = Promise.withResolvers(); + const errors: unknown[] = []; + await withApp( + { + error_handler: async (context: Context, error): Promise => { + errors.push(error); + await context.status(500).send(); + }, + }, + (app) => { + app.use(async (context: Context) => { + Promise.all([ + settledAs(context.response.sendFile(file)), + settledAs(context.response.send(silent())), + ]).then(settled.resolve, settled.reject); + const source = new PassThrough(); + source.pipe(context.response); + // In the turn of the pipe: its listener of `end`, before the one + // of the pipe (which would end the reply), throws `null` + source.prependListener("end", () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- any thrown value + throw null; + }); + // Once the file is being opened + await within(opened.promise); + source.end(); + await never(); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [500, ""]); + }, + ); + deepStrictEqual(await within(settled.promise), [ + ["rejected", null], + ["rejected", null], + ]); + deepStrictEqual(errors, [null]); + released.open(); + await within(closed(handles)); + }); + + /** + * A pipe of the stack fails, once its source ends (after `ms` + * milliseconds), while a `sendFile()` of the stack is pending: whether it + * has seen its work revoked before the cancellation (in the turn of its + * call) or not (its file is being read), it rejects with the error of the + * pipe, and the 500 is replied + */ + const pipeFailsAfter = async (ms: number): Promise => { + const released = gate(); + const handles = delay({ stating: released.promise }); + const failure = new Error("The pipe has failed"); + const settled = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + settledAs(context.response.sendFile(file)).then( + settled.resolve, + settled.reject, + ); + const source = new PassThrough(); + source.pipe(context.response); + source.prependListener("end", () => { + throw failure; + }); + if (ms > 0) { + await sleep(ms); + } + source.end(); + await never(); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [500, ""]); + }, + ); + deepStrictEqual(await within(settled.promise), ["rejected", failure]); + released.open(); + if (ms > 0) { + await within(closed(handles)); + } else { + // Its work revoked before its file is opened: it is never opened + deepStrictEqual(handles.length, 0); + } + }; + + test("cancelled by a pipe of the stack that fails in the turn of its call: it rejects with its error (never as an ignored send), and the 500 is replied", async () => { + await pipeFailsAfter(0); + }); + + test("cancelled by a pipe of the stack that fails while its file is read: it rejects with its error, and the 500 is replied", async () => { + await pipeFailsAfter(20); + }); + + test("a pipe of the stack that fails once the headers are committed cancels nothing: it is ignored, and the connection is closed", async () => { + const released = gate(); + const handles = delay({ stating: released.promise }); + const settled = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + settledAs(context.response.sendFile(file)).then( + settled.resolve, + settled.reject, + ); + context.response.flushHeaders(); + const source = new PassThrough(); + source.pipe(context.response); + source.prependListener("end", () => { + throw new Error("The pipe has failed"); + }); + // While its file is read + await sleep(20); + source.end(); + await never(); + }); + }, + async ({ port }) => { + await within( + fetch(port).then( + () => { + throw new Error("Not cut"); + }, + () => null, + ), + ); + }, + ); + released.open(); + deepStrictEqual(await within(settled.promise), ["resolved"]); + await within(closed(handles)); + }); + + test("cancelled by a pipe of the error handler that fails in the turn of its call: it rejects with its error (never as an ignored send), and the 500 is replied", async () => { + const handles = delay({}); + const failure = new Error("The pipe has failed"); + const settled = Promise.withResolvers(); + await withApp( + { + error_handler: async (context: Context): Promise => { + const source = new PassThrough(); + source.pipe(context.response); + source.prependListener("end", () => { + throw failure; + }); + // In a callback of the handler (not a continuation of a + // promise): the end of the source comes before the `sendFile()` + // goes on + await new Promise((resolve) => { + setImmediate(() => { + settledAs(context.response.sendFile(file)).then( + settled.resolve, + settled.reject, + ); + source.end(); + resolve(); + }); + }); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new Error("A failure"); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [500, ""]); + }, + ); + deepStrictEqual(await within(settled.promise), ["rejected", failure]); + // Its work revoked before its file is opened: it is never opened + deepStrictEqual(handles.length, 0); + }); + }); + + test("a send of a stream of the stack that settles before the pipe of the stack that fails cancels it: it rejects with the error of the pipe", async () => { + const failure = new Error("The pipe has failed"); + const settled = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const other = silent(); + reasonOf(context.response.send(other)).then( + settled.resolve, + settled.reject, + ); + const source = new PassThrough(); + source.pipe(context.response); + source.prependListener("end", () => { + // Its `close` (a premature close) comes before the cancellation + other.destroy(); + throw failure; + }); + source.end(); + await never(); + }); + }, + async ({ port }) => { + const received = await fetch(port); + deepStrictEqual([received.status, received.body], [500, ""]); + }, + ); + deepStrictEqual(await within(settled.promise), failure); + }); +}); + +suite("Operations of a response (sealed by its own checks)", () => { + /** Handles one request on a server of `BindenResponse` */ + const serve = async ( + handler: (response: BindenResponse) => Promise, + ): Promise<{ status: number; body: string }> => { + const server = await listenPlain(); + try { + const handled = server.handle(async (_request, response) => + handler(response), + ); + const reply = await server.request(); + deepStrictEqual(await handled, null); + return { status: reply.status, body: reply.body.toString() }; + } finally { + await server.close(); + } + }; + + test("the callbacks of a stream run as the owner that has sent it", async () => { + const first = {}; + const second = {}; + let owner: object = first; + const source = silent(); + const reply = await serve(async (response) => { + response[kSeal]((current) => current === owner); + const sent = reasonOf(owners.run(first, () => response.send(source))); + // The owner changes before the first chunk, which comes from its work + owner = second; + await owners.run(second, async () => { + source.push("late"); + await sleep(10); + // Released at once + ok(source.destroyed); + ok(!response.headersSent); + await response.status(503).send("second"); + }); + // Released: it has not been sent + deepStrictEqual( + ((await sent) as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + }); + deepStrictEqual(reply, { status: 503, body: "second" }); + }); + + test("the callbacks of a stream keep the rights of its owner, whoever emits them", async () => { + const first = {}; + const second = {}; + const source = silent(); + const reply = await serve(async (response) => { + response[kSeal]((current) => current === second); + const sent = owners.run(second, () => response.send(source)); + // Flowing: a chunk pushed now is emitted at once + await new Promise((resolve) => { + setImmediate(resolve); + }); + // Pushed by the work of an owner that may not reply + owners.run(first, () => { + source.push("data"); + source.push(null); + }); + await sent; + }); + deepStrictEqual(reply, { status: 200, body: "data" }); + }); + + test("a cancelled send ignores its source, which it has released", async () => { + const source = silent(); + let rejection: unknown = null; + const reason = new Error("Cancelled"); + const reply = await serve(async (response) => { + response.setHeader("Content-Range", "bytes 0-3/4"); + const sent = reasonOf(response.send(source)); + ok(response.started); + response[kCancel](reason); + ok(!response.started); + ok(source.destroyed); + ok(!response.hasHeader("Content-Range")); + rejection = await sent; + // A source that emits after its release + source.emit("data", "late"); + source.emit("end"); + await response.status(503).send("reply"); + }); + deepStrictEqual(reply, { status: 503, body: "reply" }); + deepStrictEqual(rejection, reason); + }); + + test("[kCancel]() once the headers are committed cancels nothing", async () => { + const source = silent(); + const reply = await serve(async (response) => { + response.setHeader("Content-Length", 4); + const sent = response.send(source); + response.flushHeaders(); + response[kCancel](new Error("Cancelled")); + ok(response.started); + source.push("data"); + source.push(null); + await sent; + }); + deepStrictEqual(reply, { status: 200, body: "data" }); + }); + + test("[kOperation]()", async () => { + let allowed = true; + const reply = await serve(async (response) => { + response[kSeal](() => allowed); + const operation = response[kOperation]() as IResponseOperation; + ok(operation.active); + // Not ended: no transfer + operation.transfer(); + allowed = false; + deepStrictEqual(response[kOperation](), null); + ok(!operation.active); + operation.transfer(); + deepStrictEqual( + operation.run(() => response.setHeader("X-Revoked", "1")), + response, + ); + ok(!response.hasHeader("X-Revoked")); + allowed = true; + operation.run(() => response.write("data")); + // Ended: its transfer goes on once its owner is revoked + response.end(); + operation.transfer(); + allowed = false; + ok(operation.active); + operation.run(() => { + response.addTrailers({ "X-Trailer": "1" }); + }); + ok(!response.destroyed); + await sleep(10); + }); + deepStrictEqual(reply, { status: 200, body: "data" }); + }); + + test("the native methods once allowed", async () => { + const reply = await serve(async (response) => { + response[kSeal](() => true); + const informational = Promise.all([ + new Promise((resolve) => { + response.writeContinue(resolve); + }), + new Promise((resolve) => { + response.writeProcessing(resolve); + }), + new Promise((resolve) => { + response.writeEarlyHints({ link: "; rel=preload" }, resolve); + }), + ]); + response.setHeaders(new Map([["X-Map", "1"]])); + response.appendHeader("X-Map", "2"); + response.statusMessage = "Fine"; + response.cookies.add(new Cookie({ key: "a", value: "1" })); + response.cookies.clear(); + response.writeHead(200, { Trailer: "X-Trailer" }); + response.addTrailers({ "X-Trailer": "1" }); + await new Promise((resolve) => { + response.end("done", resolve); + }); + await informational; + deepStrictEqual( + [response.getHeader("X-Map"), response.statusMessage], + [["1", "2"], "Fine"], + ); + }); + deepStrictEqual(reply, { status: 200, body: "done" }); + }); + + test("a response sealed without checks: only the framework mutates it", () => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response[kSeal](); + response.status(404); + response.statusCode = 404; + deepStrictEqual(response.statusCode, 200); + response[kFramework](() => { + response.statusCode = 404; + }); + deepStrictEqual(response.statusCode, 404); + }); +}); diff --git a/packages/binden/test/paused-sources.test.ts b/packages/binden/test/paused-sources.test.ts new file mode 100644 index 00000000..a311d93d --- /dev/null +++ b/packages/binden/test/paused-sources.test.ts @@ -0,0 +1,208 @@ +import { Binden, type Context } from "../index.js"; +import { PassThrough, Readable } from "node:stream"; +import { capture, listen, send, sleep, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { connect } from "node:net"; +import { once } from "node:events"; + +/** How a send has settled: `resolved`, or its error (its `code`, or itself) */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => (error as { code?: unknown }).code ?? error, + ); +} + +/** A source of one chunk, paused (`readableFlowing === false`) by `pause()` or by an `unpipe()` */ +function paused(how: "pause" | "unpipe", chunk = "hello"): Readable { + const source = new Readable({ + read(): void { + this.push(chunk); + this.push(null); + }, + }); + if (how === "pause") { + source.pause(); + } else { + // E.g. a tee that is detached + const side = new PassThrough(); + source.pipe(side); + source.unpipe(side); + } + return source; +} + +/** Runs the middleware for one request, and resolves with the reply and the outcome of its send */ +async function replied( + middleware: (context: Context) => Promise, + request: Parameters[1] = {}, +): Promise<[number, string, unknown]> { + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + await middleware(context).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve((error as { code?: unknown }).code ?? error); + throw error; + }, + ); + }, + ); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port, { + ms: 2000, + ...request, + }); + return [status, body, await within(outcome.promise)]; + } finally { + await listening.close(); + } +} + +suite("A paused Readable", () => { + for (const how of ["pause", "unpipe"] as const) { + test(`(by ${how === "pause" ? "`pause()`" : "an `unpipe()`"}) is read as the body, as \`pipe()\` reads it`, async () => { + const source = paused(how); + deepStrictEqual(source.readableFlowing, false); + deepStrictEqual(await replied((context) => context.send(source)), [ + 200, + "hello", + "resolved", + ]); + }); + } + + test("is destroyed without being read for `HEAD`", async () => { + let reads = 0; + const source = new Readable({ + read(): void { + reads += 1; + this.push(null); + }, + }); + source.pause(); + deepStrictEqual( + await replied((context) => context.send(source), { method: "HEAD" }), + [200, "", "resolved"], + ); + deepStrictEqual([source.destroyed, reads], [true, 0]); + }); + + for (const how of ["pause", "unpipe"] as const) { + test(`the request of the response (paused by ${how === "pause" ? "`pause()`" : "an `unpipe()`, as by a body parser that refuses it"}) is echoed`, async () => { + deepStrictEqual( + await replied( + async (context) => { + const { request } = context; + if (how === "pause") { + request.pause(); + } else { + const sink = new PassThrough(); + request.pipe(sink); + request.unpipe(sink); + } + deepStrictEqual(request.readableFlowing, false); + await context.send(request); + }, + { + method: "POST", + headers: { "Content-Length": 5 }, + body: "hello", + }, + ), + [200, "hello", "resolved"], + ); + }); + } + + test("whose `resume()` throws: the send rejects with its error at once, which can still be replied", async () => { + const failure = new Error("Not resumed"); + const source = new (class extends Readable { + // eslint-disable-next-line class-methods-use-this -- it never resumes + public override resume(): this { + throw failure; + } + })({ + read(): void { + // Never read + }, + }); + source.pause(); + const reply = await replied(async (context) => { + const error = await outcomeOf(context.send(source)); + // Destroyed with it, once the send has settled + deepStrictEqual( + [error, context.response.started, source.destroyed], + [failure, false, true], + ); + throw error; + }); + deepStrictEqual(reply, [500, "", failure]); + }); + + test( + "while the response needs a drain: stays paused until it drains, then is read", + { + // Windows takes a whole write while its send buffer has room: the + // response may not need a drain long enough + skip: process.platform === "win32", + }, + async () => { + const size = 8 * 1024 * 1024; + const waiting = Promise.withResolvers(); + const outcome = Promise.withResolvers(); + let reads = 0; + const source = new Readable({ + read(): void { + reads += 1; + this.push("tail"); + this.push(null); + }, + }); + source.pause(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + const { response } = context; + // Its client does not read yet + response.write(Buffer.alloc(size, "a")); + const needed = response.writableNeedDrain; + const sent = outcomeOf(context.send(source)); + await sleep(50); + waiting.resolve([needed, source.readableFlowing, reads]); + outcome.resolve(await sent); + }, + ); + const listening = await listen(app); + const socket = connect(listening.port, "127.0.0.1"); + try { + await once(socket, "connect"); + let tail = ""; + socket + .on("data", (chunk: Buffer) => { + tail = (tail + chunk.toString("latin1")).slice(-100); + }) + .on("error", () => { + // Closed by the server: `close` follows + }) + .pause(); + socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + deepStrictEqual(await within(waiting.promise), [true, false, 0]); + socket.resume(); + deepStrictEqual(await within(outcome.promise), "resolved"); + await within(once(socket, "close")); + ok(tail.endsWith("4\r\ntail\r\n0\r\n\r\n"), tail); + deepStrictEqual(reads, 1); + } finally { + socket.destroy(); + await listening.close(); + } + }, + ); +}); diff --git a/packages/binden/test/pending-decisions.test.ts b/packages/binden/test/pending-decisions.test.ts new file mode 100644 index 00000000..324b3555 --- /dev/null +++ b/packages/binden/test/pending-decisions.test.ts @@ -0,0 +1,390 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { + type IStreamLikeMethods, + decide, + decidedFor, + emitterMethods, + guard, + readStreamLike, + resolved, + streamLikeOf, +} from "../src/stream-like.js"; +import { capture, ioTurn, listen, send } from "./helpers.js"; +import { deepStrictEqual, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import type { Readable } from "node:stream"; + +/** A stream-like source of its own, which records the arguments of each destroy */ +class Source extends EventEmitter { + public readonly destroys: unknown[][] = []; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(...args: unknown[]): this { + this.destroys.push(args); + return this; + } +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +/** The values that a report (of a late error, or of a refusal) has been given */ +function reporter(): [unknown[], (error: unknown) => void] { + const reported: unknown[] = []; + return [ + reported, + (error): void => { + reported.push(error); + }, + ]; +} + +/** The reason of a cancellation, which no test expects */ +function cancelled(): Error { + return new Error("Cancelled"); +} + +/** An emitter whose listener of `newListener` throws `value` once a listener of `event` is added */ +function refusing(event: string, value: unknown): EventEmitter { + const source = new EventEmitter(); + source.on("newListener", (added: string) => { + if (added === event) { + throw value; + } + }); + return source; +} + +suite("decide()", () => { + test("returns what its call returns, and its decision ends once the call has returned or thrown", async () => { + const failure = new Error("Decided"); + const [reports, report] = reporter(); + const source = new EventEmitter(); + guard(source, emitterMethods, report); + const returned = decide(source, failure, () => { + source.emit("error", failure); + return "returned"; + }); + throws( + () => + decide(source, failure, () => { + source.emit("error", failure); + throw new Error("Thrown"); + }), + { message: "Thrown" }, + ); + // Not even on a later turn + await ioTurn(); + deepStrictEqual([returned, reports], ["returned", []]); + // No longer decided: reported, once + source.emit("error", failure); + source.emit("error", failure); + await ioTurn(); + deepStrictEqual(reports, [failure]); + }); + + test("of an error of one source: another source that emits it meanwhile reports it", async () => { + const failure = new Error("Decided for one source"); + const [reports, report] = reporter(); + const [first, second] = [new EventEmitter(), new EventEmitter()]; + guard(first, emitterMethods, report); + guard(second, emitterMethods, report); + decide(first, failure, () => { + first.emit("error", failure); + second.emit("error", failure); + }); + await ioTurn(); + deepStrictEqual(reports, [failure]); + }); + + test("never of a primitive (only an object can be decided): it is reported, and a refusal with it is logged", async () => { + const [reports, report] = reporter(); + const [refusals, refused] = reporter(); + const source = new EventEmitter(); + guard(source, emitterMethods, report); + const refuses = refusing("error", "thrown"); + decide(source, "emitted", () => { + source.emit("error", "emitted"); + }); + decide(refuses, "thrown", () => + guard(refuses, emitterMethods, report, false, refused), + ); + await ioTurn(); + deepStrictEqual([reports, refusals], [["emitted"], ["thrown"]]); + }); + + test("nests with the same error of the same source: an inner decision that ends first leaves the outer one pending (R2-1)", async () => { + const failure = new Error("Decided twice"); + const [reports, report] = reporter(); + const source = new EventEmitter(); + guard(source, emitterMethods, report); + decide(source, failure, () => { + decide(source, failure, () => { + source.emit("error", failure); + }); + // Still decided by the outer one + source.emit("error", failure); + }); + await ioTurn(); + deepStrictEqual(reports, []); + source.emit("error", failure); + await ioTurn(); + deepStrictEqual(reports, [failure]); + }); + + test("nests: a send of the same source with the same error that its destroy starts, whose decision ends first, leaves the outer one pending (R2-1)", async () => { + const failure = new Error("Failed before the send"); + const [reports, report] = reporter(); + // Failed without being destroyed (its `errored`): its send destroys it + // with that error, and decides it + const source = Object.assign(new Source(), { errored: failure }); + const inner: Readable[] = []; + Object.assign(source, { + destroy(error?: Error): Source { + Object.assign(source, { destroyed: true }); + // Another send of it, destroyed with the same error, which decides + // it as well, and returns before the outer one + const read = readStreamLike( + source, + methodsOf(source), + cancelled, + report, + ); + inner.push(read.on("error", () => null)); + source.emit("error", error); + return source; + }, + }); + const outer = readStreamLike( + source, + methodsOf(source), + cancelled, + report, + ).on("error", () => null); + await ioTurn(); + deepStrictEqual( + [outer.errored, inner.map(({ errored }) => errored), reports], + [failure, [failure], []], + ); + }); + + test("nests with another error of the same source: each ends with its own call", async () => { + const [outer, inner] = [new Error("Outer"), new Error("Inner")]; + const [reports, report] = reporter(); + const source = new EventEmitter(); + guard(source, emitterMethods, report); + decide(source, outer, () => { + decide(source, inner, () => { + source.emit("error", outer); + source.emit("error", inner); + }); + source.emit("error", outer); + source.emit("error", inner); + }); + await ioTurn(); + deepStrictEqual(reports, [inner]); + }); +}); + +suite("decidedFor()", () => { + test("records an error for its source only: not reported for it, but for another source that emits it (R1 F2)", async () => { + const failure = new Error("One error of the application for every stream"); + const [reports, report] = reporter(); + const [first, second] = [new EventEmitter(), new EventEmitter()]; + guard(first, emitterMethods, report); + guard(second, emitterMethods, report); + decidedFor(first, failure); + first.emit("error", failure); + second.emit("error", failure); + await ioTurn(); + deepStrictEqual(reports, [failure]); + }); + + test("also for a refusal of the guard of its source: with that error, not logged, but for another source", () => { + const failure = new Error("Decided, then thrown for the guard"); + const [refusals, refused] = reporter(); + const [first, second] = [ + refusing("error", failure), + refusing("error", failure), + ]; + decidedFor(first, failure); + const guarded = [first, second].map((source) => + guard(source, emitterMethods, () => null, false, refused), + ); + deepStrictEqual([guarded, refusals], [[false, false], [failure]]); + }); + + test("never of a primitive (only an object can be recorded): it is reported each time", async () => { + const [reports, report] = reporter(); + const source = new EventEmitter(); + guard(source, emitterMethods, report); + decidedFor(source, "emitted"); + source.emit("error", "emitted"); + source.emit("error", "emitted"); + await ioTurn(); + deepStrictEqual(reports, ["emitted", "emitted"]); + }); +}); + +suite( + "A stream-like source whose `pause()` throws an error of the application, which decides its send", + () => { + test("is destroyed with it, which is not reported for it once its send has rejected with it, but for another source that emits it (R1 F2, R23-C1)", async () => { + const failure = new Error("One error of the application"); + const [reports, report] = reporter(); + const source = Object.assign(new Source(), { + pause(): never { + throw failure; + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + cancelled, + report, + ).on("error", () => null); + // More than its high water mark: it is full, and paused + source.emit("data", Buffer.alloc(readable.readableHighWaterMark)); + // As the send does once it rejects with it (see `brought` of the + // response): only then is it decided for the source + decidedFor(readable, failure); + const other = new EventEmitter(); + guard(other, emitterMethods, report); + source.emit("error", failure); + other.emit("error", failure); + await ioTurn(); + deepStrictEqual( + [readable.errored, source.destroys, reports], + [failure, [[failure]], [failure]], + ); + }); + }, +); + +suite("resolved()", () => { + test("a readable of a stream-like source whose send has resolved, once released, destroys the source without an error, never with the reason (R2 R21-H3)", () => { + const source = new Source(); + let reasons = 0; + const readable = readStreamLike( + source, + methodsOf(source), + () => { + reasons += 1; + return new Error("Cancelled"); + }, + () => null, + ); + resolved(readable); + readable.destroy(); + deepStrictEqual( + [source.destroys, reasons, readable.errored], + [[[]], 0, null], + ); + }); + + test("a readable destroyed with an error destroys the source with it all the same", () => { + const source = new Source(); + const failure = new Error("A failure once resolved"); + const readable = readStreamLike( + source, + methodsOf(source), + cancelled, + () => null, + ); + resolved(readable); + readable.on("error", () => null).destroy(failure); + deepStrictEqual(source.destroys, [[failure]]); + }); + + test("without it, a readable released without an error destroys the source with the reason", () => { + const source = new Source(); + const reason = new Error("The reason of a cancellation"); + const readable = readStreamLike( + source, + methodsOf(source), + () => reason, + () => null, + ); + readable.destroy(); + deepStrictEqual(source.destroys, [[reason]]); + }); +}); + +suite( + "A stream-like source whose code throws one value for the listener of `data` of its send, then for a listener of its guard", + () => { + for (const event of ["error", "close"] as const) { + for (const [kind, value, logged] of [ + ["an object", new Error("Thrown"), 0], + ["a string", "thrown", 1], + ] as const) { + test(`(of its \`${event}\`), ${kind}: rejects with it, which is ${logged === 0 ? "not logged (it has decided the send)" : "logged once (only an object can be decided, R3.21-2)"}`, async () => { + const captured = capture(); + const outcomes: unknown[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const source = Object.assign(new EventEmitter(), { + pause(): void { + // Never full + }, + resume(): void { + // Emits nothing + }, + destroy(): void { + // Emits nothing + }, + }); + const counts = new Map(); + source.on("newListener", (added: string) => { + const count = (counts.get(added) ?? 0) + 1; + counts.set(added, count); + // The second one of `event` is the one of its guard + if (added === "data" || (added === event && count === 2)) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- also a primitive, as code of a source may throw + throw value; + } + }); + await context.send(source).catch((error: unknown) => { + outcomes.push(error); + }); + await context.response.status(500).send("failed"); + }); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + await ioTurn(); + const messages = captured.records + .map(({ msg }) => msg) + .filter( + (msg) => + msg === "The release of a stream has failed" || + msg === releasedFailure, + ); + deepStrictEqual( + [status, outcomes, messages], + [ + 500, + [value], + logged === 0 ? [] : ["The release of a stream has failed"], + ], + ); + } finally { + await listening.close(); + } + }); + } + } + }, +); diff --git a/packages/binden/test/pipes.test.ts b/packages/binden/test/pipes.test.ts new file mode 100644 index 00000000..bdee3977 --- /dev/null +++ b/packages/binden/test/pipes.test.ts @@ -0,0 +1,3414 @@ +import { + Binden, + BindenError, + BindenResponse, + type Context, + type IBindenOptions, + shutdown, +} from "../index.js"; +import { EventEmitter, once } from "node:events"; +import { + type ICaptured, + type IListening, + capture, + ioTurn, + listen, + never, + pollUntil, + sleep, + within, +} from "./helpers.js"; +import { + PassThrough, + Readable, + Stream, + Transform, + finished, + pipeline, +} from "node:stream"; +import { Socket, connect } from "node:net"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { kListen, kOwnership, kUnlisten, owners } from "../src/seal.js"; +import { suite, test } from "node:test"; +import { IncomingMessage } from "node:http"; +import { createRequire } from "node:module"; +import { pipeline as pipelineAsync } from "node:stream/promises"; + +/** 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(); + } +} + +/** A response as the client has received it on its connection */ +interface IRaw { + /** Everything received (e.g. a second reply after the first one) */ + readonly raw: string; + readonly status: number | null; + readonly head: string; + /** The body, decoded from its chunks */ + readonly body: string; + /** Whether the body has been received in full (as framed) */ + readonly complete: boolean; + /** Whether the server has closed the connection */ + readonly closed: boolean; +} + +/** The body framed by its head in `rest`, and whether it is complete */ +function bodyOf(head: string, rest: string): [string, boolean] { + if (/^Transfer-Encoding: chunked$/imu.test(head)) { + let body = ""; + let at = 0; + for (;;) { + const end = rest.indexOf("\r\n", at); + if (end === -1) { + return [body, false]; + } + const size = Number.parseInt(rest.slice(at, end), 16); + if (size === 0) { + return [body, rest.startsWith("\r\n", end + 2)]; + } else if (rest.length < end + 2 + size + 2) { + return [body + rest.slice(end + 2), false]; + } + body += rest.slice(end + 2, end + 2 + size); + at = end + 2 + size + 2; + } + } + const length = Number(/^Content-Length: (?\d+)$/imu.exec(head)?.groups?.n); + return Number.isNaN(length) + ? [rest, false] + : [rest.slice(0, length), rest.length >= length]; +} + +/** + * Sends a request on a new connection and reads its response: until it is + * complete (and `linger` more milliseconds, for anything that follows), or + * the connection is closed, within `ms` (once the end of the connection has + * been read, until its `close` itself) + */ +async function exchange( + port: number, + { path = "/", linger = 30, ms = 10_000 } = {}, +): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let raw = ""; + let closed = false; + const { promise: closing, resolve: close } = Promise.withResolvers(); + socket.setEncoding("latin1").on("data", (chunk: string) => { + raw += chunk; + }); + socket.on("close", () => { + closed = true; + close(null); + }); + socket.write(`GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`); + const parse = (): IRaw => { + const end = raw.indexOf("\r\n\r\n"); + const head = end === -1 ? raw : raw.slice(0, end); + const [body, complete] = + end === -1 ? ["", false] : bodyOf(head, raw.slice(end + 4)); + const status = /^HTTP\/1\.1 (?\d{3})/u.exec(head)?.groups?.code; + return { + raw, + status: typeof status === "undefined" ? null : Number(status), + head, + body, + complete, + closed, + }; + }; + const deadline = Date.now() + ms; + try { + for ( + let reply = parse(); + !reply.closed && !reply.complete && Date.now() < deadline; + reply = parse() + ) { + await sleep(5); + } + await sleep(linger); + // What has come meanwhile (e.g. its close), once a stall has passed + await ioTurn(); + if (socket.readableEnded) { + // Its `close` follows + await within(closing); + } + return parse(); + } finally { + socket.destroy(); + } +} + +/** Whether the error is the `503` of a deadline */ +function isDeadline(error: unknown): boolean { + return error instanceof BindenError && error.code === "deadline"; +} + +/** A response without a connection: nothing is actually sent */ +function detached(): BindenResponse { + return new BindenResponse(new IncomingMessage(new Socket())); +} + +/** A source that never ends (nor pushes anything by itself) */ +function silent(): PassThrough { + return new PassThrough(); +} + +/** + * Feeds a source from a timer created outside of any request (a work of no + * request, e.g. a shared broadcaster): its chunks once `start()` is called, + * then its end + */ +function feeder( + source: PassThrough, + chunks: readonly string[], +): { start(): void; stop(): void } { + let started = false; + const pending = [...chunks]; + const timer = owners.exit(() => + setInterval(() => { + if (!started) { + return; + } + const chunk = pending.shift(); + if (typeof chunk === "undefined") { + clearInterval(timer); + source.end(); + } else { + source.write(chunk); + } + }, 5), + ); + return { + start: (): void => { + started = true; + }, + stop: (): void => { + clearInterval(timer); + }, + }; +} + +/** The messages of the records of a captured logger, at a level */ +function messages(captured: ICaptured, level: number): unknown[] { + return captured.records + .filter((record) => record.level === level) + .map(({ msg }) => msg); +} + +/** A head that Node.js refuses (an invalid reason phrase) */ +const refused = "bad\n"; + +const require = createRequire(import.meta.url); + +/** The streams of a version of `readable-stream` (typed as those of Node.js) */ +interface IReadableStream { + readonly PassThrough: new () => PassThrough; +} + +/** `readable-stream` 2 and 3 (the streams of Node.js 10), and 4 (a control) */ +const libraries: readonly (readonly [string, IReadableStream])[] = [ + ["2", require("readable-stream-v2") as IReadableStream], + ["3", require("readable-stream-v3") as IReadableStream], + ["4", require("readable-stream-v4") as IReadableStream], +]; + +/** `through2` 2 (a transform of `readable-stream` 2) */ +const through2 = require("through2-v2") as ( + transform: ( + chunk: Buffer, + encoding: string, + callback: (error: null, chunk: string) => void, + ) => void, +) => PassThrough; + +/** + * Wraps `on()` and `emit()` on a response, as an instrumentation does (e.g. + * with `shimmer`): the wrappers call the methods of the response + */ +function instrument(response: BindenResponse): void { + for (const name of ["on", "emit"] as const) { + const original = Reflect.get(response, name) as ( + ...args: unknown[] + ) => unknown; + response[name] = function wrapper( + this: BindenResponse, + ...args: unknown[] + ): unknown { + return Reflect.apply(original, this, args); + } as never; + } +} + +suite("A pipe into the response", () => { + suite( + "adopted (its owner writes and ends it, whatever work feeds it)", + () => { + test("`pipeline()` of an error handler, fed by a work of no request, ends the reply", async () => { + const source = silent(); + const feed = feeder(source, ["REAL", "!"]); + try { + await withApp( + { + timeout: 20, + error_handler_timeout: 2000, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + const piped = pipelineAsync(source, context.response); + feed.start(); + await piped; + }, + }, + (app) => { + app.use(async () => { + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete, reply.closed], + [503, "REAL!", true, false], + ); + deepStrictEqual(messages(captured, 50), []); + }, + ); + } finally { + feed.stop(); + } + }); + + test("a listener that another work adds to the source in the same turn writes nothing", async () => { + const source = silent(); + const feed = feeder(source, ["REAL"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + source.pipe(context.response); + feed.start(); + await new Promise((resolve) => { + context.response.once("finish", resolve); + }); + }, + }, + (app) => { + app.use(async (context: Context) => { + // Of the abandoned work: prepended, it runs before the + // listeners of the pipe + context.response.prependListener("pipe", (piped: Readable) => { + piped.on("data", () => { + context.response.write("OLD"); + }); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [503, "REAL", true], + ); + }, + ); + } finally { + feed.stop(); + } + }); + + for (const first of ["/a", "/b"]) { + test(`one source of the works of two requests (${first} pipes first in the turn): the end of each fails its own work`, async () => { + const source = silent(); + const start = Promise.withResolvers(); + const waiting: string[] = []; + const settled: Record = {}; + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/a") { + context.response.statusMessage = refused; + } + waiting.push(pathname); + await start.promise; + pipelineAsync(source, context.response).then( + () => { + settled[pathname] = null; + }, + (error: unknown) => { + settled[pathname] = (error as { code?: string }).code; + }, + ); + await never(); + }); + }, + async ({ port }) => { + const order = first === "/a" ? ["/a", "/b"] : ["/b", "/a"]; + const replies: Promise[] = []; + for (const path of order) { + replies.push(exchange(port, { path })); + await pollUntil(() => waiting.length >= replies.length); + } + // Both pipe in the same turn, in that order + start.resolve(null); + // Its end from a work of no request + owners.exit(() => { + setTimeout(() => { + source.end(); + }, 10); + }); + const [one, two] = (await Promise.all(replies)) as [IRaw, IRaw]; + const [a, b] = first === "/a" ? [one, two] : [two, one]; + deepStrictEqual([a.status, a.complete], [500, true]); + deepStrictEqual([b.status, b.body, b.complete], [200, "", true]); + deepStrictEqual(settled, { + "/a": "ERR_INVALID_CHAR", + "/b": null, + }); + }, + ); + }); + } + + test("one source of a failed work and of an independent destination: the destination gets every chunk", async () => { + const source = silent(); + const feed = feeder(source, ["x", "y"]); + const destination = new PassThrough(); + let collected = ""; + destination.setEncoding("latin1").on("data", (chunk: string) => { + collected += chunk; + }); + const ended = once(destination, "end"); + owners.exit(() => { + source.pipe(destination); + }); + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + // Its first write fails + context.response.statusMessage = refused; + source.pipe(context.response); + feed.start(); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + await within(ended); + deepStrictEqual([collected, source.errored], ["xy", null]); + }, + ); + } finally { + feed.stop(); + } + }); + + test("a throwing `end` listener that the abandoned work adds to the source from its listener of `pipe` is logged", async () => { + const source = silent(); + const feed = feeder(source, ["REAL"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + source.pipe(context.response); + feed.start(); + await once(context.response, "finish"); + }, + }, + (app) => { + app.use(async (context: Context) => { + context.response.prependListener("pipe", (piped: Readable) => { + // In the window of the pipe of the error handler + piped.once("end", () => { + throw new Error("A listener of the application has failed"); + }); + }); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [503, "REAL", true], + ); + await captured.waitFor("A pipe of an abandoned work has failed"); + }, + ); + } finally { + feed.stop(); + } + }); + + test("a source that has ended already ends the reply as its owner", async () => { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + const ended = new PassThrough(); + ended.end(); + ended.resume(); + await once(ended, "end"); + context.response.status(503); + ended.pipe(context.response); + await once(context.response, "finish"); + }, + }, + (app) => { + app.use(async () => { + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [503, "", true], + ); + }, + ); + }); + + test("with `{ end: false }`, the owner ends the reply itself", async () => { + const source = silent(); + const feed = feeder(source, ["a", "b"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + source.pipe(context.response, { end: false }); + feed.start(); + await once(source, "end"); + context.response.end("c"); + }, + }, + (app) => { + app.use(async () => { + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [503, "abc", true], + ); + }, + ); + } finally { + feed.stop(); + } + }); + + test("two responses pipe one source in one turn: both bodies are exact", async () => { + const source = silent(); + const feed = feeder(source, ["x", "y"]); + const start = Promise.withResolvers(); + const count = { waiting: 0, piped: 0 }; + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + count.waiting += 1; + await start.promise; + source.pipe(context.response); + count.piped += 1; + if (count.piped === 2) { + // The patches of the window, until the end of the turn + ok(Object.hasOwn(source, "on")); + feed.start(); + } + await once(context.response, "finish"); + }); + }, + async ({ port }) => { + const replies = Promise.all([exchange(port), exchange(port)]); + await pollUntil(() => count.waiting >= 2); + // Both pipe in the same turn + start.resolve(null); + const [first, second] = await replies; + deepStrictEqual(count.piped, 2); + deepStrictEqual( + [first.body, second.body, first.complete, second.complete], + ["xy", "xy", true, true], + ); + // Restored: the methods of its class + ok( + !Object.hasOwn(source, "on") && !Object.hasOwn(source, "once"), + ); + }, + ); + } finally { + feed.stop(); + } + }); + }, + ); + + suite("of an abandoned work (its response is closed for it)", () => { + for (const [name, end] of [ + [ + "ends", + (source: PassThrough): void => { + source.push(null); + }, + ], + [ + "destroys", + (source: PassThrough): void => { + source.destroy(); + }, + ], + ] as const) { + for (const streams of [2, 3]) { + test(`its \`pipeline()\` of ${streams} streams settles at once, and never ends the reply when the new owner ${name} its source`, async () => { + const source = silent(); + const settled = Promise.withResolvers(); + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + end(source); + await sleep(20); + // Finished at the abandonment: it has not ended the reply + deepStrictEqual(await within(settled.promise, 0), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + await context.status(503).send("new"); + }, + }, + (app) => { + app.use(async (context: Context) => { + const piped = + streams === 2 + ? pipelineAsync(source, context.response) + : pipelineAsync( + source, + new PassThrough(), + context.response, + ); + piped.then( + () => { + settled.resolve(null); + }, + (error: unknown) => { + settled.resolve({ + code: (error as { code?: string }).code, + }); + }, + ); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete, reply.closed], + [503, "new", true, false], + ); + }, + ); + }); + } + } + + test("its plain pipe of a source that the new owner pipes is unpiped: the new body is exact, the source alive", async () => { + const source = silent(); + const feed = feeder(source, ["REAL"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + ok(!source.destroyed); + context.response.status(503).type("text/plain"); + source.pipe(context.response); + feed.start(); + await once(context.response, "finish"); + }, + }, + (app) => { + app.use(async (context: Context) => { + context.response.type("text/html"); + source.pipe(context.response); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [503, "REAL", true], + ); + ok(/^Content-Type: text\/plain/imu.test(reply.head), reply.head); + }, + ); + } finally { + feed.stop(); + } + }); + + test("an accessor of its own that it has set on a shared source refuses the pipe of the new owner: no byte of it", async () => { + const source = silent(); + const feed = feeder(source, ["REAL"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + source.pipe(context.response); + feed.start(); + await once(context.response, "finish"); + }, + }, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + const state: unknown = Reflect.get(source, "_readableState"); + let armed = true; + // Read by `on("data")` of Node.js once it has added the + // listener of the pipe of the new owner + Object.defineProperty(source, "_readableState", { + configurable: true, + enumerable: true, + get(this: PassThrough): unknown { + if ( + armed && + EventEmitter.prototype.listenerCount.call(this, "data") === + 1 + ) { + armed = false; + EventEmitter.prototype.on.call(this, "data", () => { + response.write("OLD"); + }); + } + return state; + }, + set(): void { + // Ignored + }, + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + ok(!reply.raw.includes("OLD"), reply.raw); + }, + ); + } finally { + feed.stop(); + } + }); + + test("its pipe whose own `close` listener is gone is unpiped all the same", async () => { + const source = silent(); + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + await context.status(503).send(`${source.listenerCount("data")}`); + }, + }, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + source.pipe(response); + // The `close` listener of the pipe of Node.js (its `onclose()`) + for (const listener of response.rawListeners("close")) { + const wrapped = Reflect.get(listener, "listener") as + (() => void) | undefined; + const { name } = wrapped ?? listener; + if (name === "onclose") { + response.removeListener("close", listener as () => void); + } + } + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.body], [503, "0"]); + }, + ); + }); + + test("its `close` listeners run once, at the abandonment; those of the framework at the real close", async () => { + const calls: string[] = []; + const cleaned = Promise.withResolvers(); + await withApp( + { + timeout: 20, + access_log: true, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + // Before the reply of the deadline + deepStrictEqual(calls, ["close"]); + calls.push("reply"); + await context.status(503).send("deadline!"); + }, + }, + (app) => { + app.use(async (context: Context) => { + context.onCleanup(() => { + calls.push("cleanup"); + cleaned.resolve(null); + }); + context.response.on("close", () => { + calls.push("close"); + }); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.body], [503, "deadline!"]); + await within(cleaned.promise); + deepStrictEqual(calls, ["close", "reply", "cleanup"]); + const logged = captured.records.filter( + ({ msg }) => msg === "The request has completed", + ); + deepStrictEqual( + logged.map(({ status, body_bytes }) => [status, body_bytes]), + [[503, 9]], + ); + }, + ); + }); + + test("a `close` listener that it adds later runs at once, and `finished()` settles", async () => { + const outcome = Promise.withResolvers(); + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + await sleep(30); + await context.status(503).send("deadline"); + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(40); + finished(context.response, (error) => { + // At once: before the reply of the deadline has been sent + // (whatever the load, its timer comes after this one) + outcome.resolve([ + (error as { code?: string } | undefined)?.code, + context.response.headersSent, + ]); + }); + await never(); + }); + }, + async ({ port }) => { + const replied = exchange(port); + deepStrictEqual(await within(outcome.promise), [ + "ERR_STREAM_PREMATURE_CLOSE", + false, + ]); + deepStrictEqual((await replied).status, 503); + }, + ); + }); + + test("the `close` listeners of an error handler that times out run before the reply that replaces it", async () => { + const sent: boolean[] = []; + await withApp( + { + error_handler_timeout: 30, + error_handler: async (context: Context): Promise => { + context.response.on("close", () => { + sent.push(context.response.headersSent); + }); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(409); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual(sent, [false]); + }, + ); + }); + + test("a `close` listener of the abandoned work that throws is logged", async () => { + await withApp( + { timeout: 20 }, + (app) => { + app.use(async (context: Context) => { + context.response.on("close", () => { + throw new Error("A cleanup has failed"); + }); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual(reply.status, 503); + await captured.waitFor("A listener of an abandoned work has failed"); + }, + ); + }); + + test("an observer of the framework (closed by the real close only) is removed by `kUnlisten`, not by `removeListener()`", () => { + const response = detached(); + let closed = 0; + const listener = (): void => { + closed += 1; + }; + response[kListen]("close", listener, { once: false }); + response.removeListener("close", listener); + response.emit("close"); + response[kUnlisten]("close", listener); + response.emit("close"); + deepStrictEqual(closed, 1); + }); + }); + + suite("that fails (its owner fails with its error)", () => { + test("of the stack, before the headers: the error handler replies, and the deadline still applies", async () => { + const seen: unknown[] = []; + await withApp( + { + timeout: 100, + error_handler: async (context: Context, error): Promise => { + if (isDeadline(error)) { + seen.push((context.signal.reason as { reason?: string }).reason); + await context.status(503).send("deadline"); + return; + } + seen.push(error instanceof TypeError, context.signal.aborted); + // Slower than the deadline: it takes the reply over + await sleep(300); + await context.status(502).send("late"); + }, + }, + (app) => { + app.use(async (context: Context) => { + // Refused (see below): its pipe fails at once + context.response.on("newListener", () => null); + Readable.from(["body"]).pipe(context.response); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port, { linger: 350 }); + deepStrictEqual( + [reply.status, reply.body, reply.complete, reply.closed], + [503, "deadline", true, false], + ); + ok(!reply.raw.includes("late"), reply.raw); + deepStrictEqual(seen, [true, false, "deadline"]); + }, + ); + }); + + for (const [form, streams] of [ + ["promise", 2], + ["promise", 3], + ["callback", 2], + ["callback", 3], + ] as const) { + test(`refused while \`pipeline()\` (${form}, ${streams} streams) is created: it settles with the error, one reply`, async () => { + const settled = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + // Code that the pipe would run while it adds its listeners + response.on("newListener", () => null); + const chain = + streams === 2 + ? [silent(), response] + : [silent(), new PassThrough(), response]; + if (form === "promise") { + pipelineAsync(chain).then( + () => { + settled.resolve(null); + }, + (error: unknown) => { + settled.resolve(error); + }, + ); + } else { + pipeline(chain, (error) => { + settled.resolve(error ?? null); + }); + } + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port, { linger: 50 }); + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [500, true, false], + ); + deepStrictEqual(reply.raw.match(/HTTP\/1\.1/gu)?.length, 1); + const error = (await within(settled.promise)) as Error; + ok(error instanceof TypeError, String(error)); + ok(error.message.includes("refused"), error.message); + const failures = captured.records + .filter(({ msg }) => msg === "The request has failed") + .map(({ failure }) => (failure as { type: string }).type); + deepStrictEqual(failures, ["TypeError"]); + }, + ); + }); + } + + test("of a middleware that has settled meanwhile: the next middleware does not run", async () => { + const ran = { next: false }; + await withApp( + {}, + (app) => { + app + .use((context: Context) => { + // Settled before its pipe fails (not `async`) + const settled = Promise.resolve(); + // Refused: its pipe fails at once + context.response.on("newListener", () => null); + silent().pipe(context.response); + return settled; + }) + .use(() => { + ran.next = true; + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual(ran, { next: false }); + }, + ); + }); + + for (const done of [false, true]) { + test(`of a synchronous middleware${done ? " that sets `done`" : ""}: the next middleware does not run`, async () => { + const ran = { next: false }; + await withApp( + {}, + (app) => { + app + .use((context: Context) => { + // Refused: its pipe fails at once + context.response.on("newListener", () => null); + silent().pipe(context.response); + context.done = done; + }) + .use(async (context: Context) => { + ran.next = true; + await context.json({ next: true }); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port, { linger: 50 }); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual(reply.raw.match(/HTTP\/1\.1/gu)?.length, 1); + deepStrictEqual(ran, { next: false }); + deepStrictEqual(messages(captured, 50), ["The request has failed"]); + }, + ); + }); + } + + test("an `error` that its listener emits again reaches no other work, nor throws", async () => { + const received: string[] = []; + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + // Of a work of no request + owners.exit(() => { + response.on("error", () => { + received.push("other"); + }); + }); + response.on("error", (error: Error) => { + received.push("own"); + response.emit("error", error); + }); + response.statusMessage = refused; + Readable.from(["body"]).pipe(response); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual(received, ["own"]); + }, + ); + }); + + test("of an error handler, whose source fails before its end: it fails at once", async () => { + const source = silent(); + await withApp( + { + error_handler_timeout: 5000, + error_handler: async (context: Context): Promise => { + context.response.status(409); + source.pipe(context.response); + owners.exit(() => { + setTimeout(() => { + source.destroy(new Error("The source has failed")); + }, 10); + }); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(409); + }); + }, + async ({ port }, captured) => { + const started = Date.now(); + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + ok(Date.now() - started < 1000); + ok( + messages(captured, 50).includes( + "The provided `error_handler` has failed", + ), + ); + }, + ); + }); + + test("of an error handler, whose source closes before its end: it fails at once", async () => { + const source = silent(); + await withApp( + { + error_handler_timeout: 5000, + error_handler: async (context: Context): Promise => { + source.pipe(context.response); + owners.exit(() => { + setTimeout(() => { + source.destroy(); + }, 10); + }); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(409); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + }, + ); + }); + + test("`pipeline()` of an error handler, whose head fails from a work of no request", async () => { + const head = silent(); + await withApp( + { + timeout: 20, + error_handler_timeout: 5000, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + const piped = pipelineAsync( + head, + new PassThrough(), + context.response, + ); + owners.exit(() => { + setTimeout(() => { + head.write("partial"); + head.destroy(new Error("The head has failed")); + }, 10); + }); + await piped; + }, + }, + (app) => { + app.use(async () => { + await never(); + }); + }, + async ({ port }) => { + const started = Date.now(); + const reply = await exchange(port); + ok(reply.status === 500 || reply.closed, reply.raw); + ok(!reply.complete || reply.status === 500, reply.raw); + ok(Date.now() - started < 1000); + }, + ); + }); + }); + + suite( + "refused (its owner fails), when code other than its source may have run", + () => { + /** Pipes a source into the response */ + const piped = (source: PassThrough, response: BindenResponse): void => { + source.pipe(response); + }; + + /** + * Pipes a source fed by a work of no request into the response, refused + * by `prepare`: by the stack, or by the error handler of its deadline + * (`handler`, e.g. for a source whose listener methods of its own the + * stack would leave to Node.js) + */ + const refusedBy = async ( + prepare: (source: PassThrough, response: BindenResponse) => void, + pipe: (source: PassThrough, response: BindenResponse) => void = piped, + work: "handler" | "stack" = "stack", + ): Promise<{ reply: IRaw; logged: unknown[] }> => { + const source = silent(); + const feed = feeder(source, ["REAL"]); + const run = async ({ response }: Context): Promise => { + response.type("text/plain"); + prepare(source, response); + pipe(source, response); + feed.start(); + await never(); + }; + try { + let result: { reply: IRaw; logged: unknown[] } | null = null; + await withApp( + work === "stack" + ? {} + : { + timeout: 20, + error_handler: async ( + context: Context, + error, + ): Promise => { + if (isDeadline(error)) { + await run(context); + } + }, + }, + (app) => { + app.use(work === "stack" ? run : never); + }, + async ({ port }, captured) => { + const reply = await exchange(port, { linger: 60 }); + result = { + reply, + logged: captured.records + .filter( + ({ msg }) => + msg === "The request has failed" || + msg === "The provided `error_handler` has failed", + ) + .map(({ failure }) => (failure as { type: string }).type), + }; + }, + ); + return result as unknown as { reply: IRaw; logged: unknown[] }; + } finally { + feed.stop(); + } + }; + + test("a listener of `newListener` of the response that adds a `data` listener to the source", async () => { + const { reply, logged } = await refusedBy((source, response) => { + response.on("newListener", (event: unknown) => { + if (event === "unpipe") { + // Named as the one of the pipe of Node.js + const ondata = (): void => { + response.write("SPOOF"); + }; + source.on("data", ondata); + } + }); + }); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + ok(!reply.raw.includes("SPOOF") && !reply.raw.includes("REAL")); + deepStrictEqual(logged, ["TypeError"]); + }); + + test("a listener of `removeListener` of the response", async () => { + const { reply } = await refusedBy((_source, response) => { + response.on("removeListener", () => null); + }); + deepStrictEqual([reply.status, reply.complete], [500, true]); + }); + + for (const event of ["newListener", "removeListener"]) { + test(`a listener of \`${event}\` of the source`, async () => { + const { reply } = await refusedBy((source) => { + source.on(event, () => null); + }); + deepStrictEqual([reply.status, reply.complete], [500, true]); + }); + } + + for (const name of [ + "on", + "once", + "emit", + "pipe", + "pause", + "resume", + "read", + ]) { + // The stack leaves a pipe of a source with listener methods of its + // own to Node.js + const work = ["on", "once"].includes(name) ? "handler" : "stack"; + test(`a method \`${name}()\` of the source itself (${work === "stack" ? "of the stack" : "of an error handler"})`, async () => { + const { reply } = await refusedBy( + (source) => { + const inherited = Reflect.get(PassThrough.prototype, name) as ( + ...args: unknown[] + ) => unknown; + Object.defineProperty(source, name, { + configurable: true, + writable: true, + value(this: PassThrough, ...args: unknown[]): unknown { + return Reflect.apply(inherited, this, args); + }, + }); + }, + piped, + work, + ); + deepStrictEqual([reply.status, reply.complete], [500, true]); + }); + } + + for (const name of ["rawListeners", "unpipe", "removeListener"]) { + const work = name === "removeListener" ? "handler" : "stack"; + test(`a method \`${name}()\` of the source itself, which binden never calls (${work === "stack" ? "of the stack" : "of an error handler"})`, async () => { + const calls: string[] = []; + const { reply } = await refusedBy( + (source) => { + const inherited = Reflect.get(PassThrough.prototype, name) as ( + ...args: unknown[] + ) => unknown; + Object.defineProperty(source, name, { + configurable: true, + writable: true, + value(this: PassThrough, ...args: unknown[]): unknown { + // Node.js calls it from the pipe that it dismantles (its + // `cleanup()`), as it would without binden + const { stack = "" } = new Error("Called"); + calls.push( + /\bat cleanup\b/u.test(stack) ? "cleanup" : "other", + ); + return Reflect.apply(inherited, this, args); + }, + }); + }, + piped, + work, + ); + deepStrictEqual([reply.status, reply.complete], [500, true]); + ok( + calls.every((call) => call === "cleanup"), + JSON.stringify(calls), + ); + }); + } + + test("a legacy `Stream.prototype.pipe()` (with no `unpipe()`), dismantled", async () => { + const legacy = new Stream() as Stream & { readable: boolean }; + legacy.readable = true; + const { reply } = await refusedBy( + () => null, + (_source, response) => { + legacy.pipe(response); + owners.exit(() => { + setTimeout(() => { + legacy.emit("data", Buffer.from("LEGACY")); + legacy.emit("end"); + }, 10); + }); + }, + ); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + ok(!reply.raw.includes("LEGACY")); + deepStrictEqual(legacy.listenerCount("data"), 0); + }); + + test("a legacy `Stream.prototype.pipe()` of a source that has ended", async () => { + const ended = new PassThrough(); + ended.end(); + ended.resume(); + await once(ended, "end"); + const { reply } = await refusedBy( + () => null, + (_source, response) => { + const legacy = Reflect.get(Stream.prototype, "pipe") as ( + destination: BindenResponse, + ) => unknown; + Reflect.apply(legacy, ended, [response]); + }, + ); + deepStrictEqual([reply.status, reply.complete], [500, true]); + }); + + test("its own `resume()` that adds a `data` listener (called by the pipe before it is adopted)", async () => { + const { reply } = await refusedBy((source, response) => { + const inherited = Reflect.get(PassThrough.prototype, "resume") as ( + ...args: unknown[] + ) => unknown; + let armed = true; + Object.defineProperty(source, "resume", { + configurable: true, + writable: true, + value(this: PassThrough, ...args: unknown[]): unknown { + if (armed) { + armed = false; + // As the pipe of Node.js, after its own listener + EventEmitter.prototype.on.call(this, "data", () => { + response.write("OLD"); + }); + } + return Reflect.apply(inherited, this, args); + }, + }); + }); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + ok(!reply.raw.includes("OLD"), reply.raw); + }); + + for (const spoof of [false, true]) { + test(`a proxy of a source${spoof ? " whose trap adds a `data` listener" : ""}: refused before its inspection, its traps run only to unpipe it`, async () => { + const traps: string[] = []; + const { reply } = await refusedBy( + () => null, + (source, response) => { + let piping = false; + const emit = Reflect.get(response, "emit") as ( + ...args: unknown[] + ) => unknown; + response.emit = function wrapper( + this: BindenResponse, + ...args: unknown[] + ): boolean { + piping = args[0] === "pipe"; + try { + return Reflect.apply(emit, this, args) as boolean; + } finally { + piping = false; + } + } as never; + const trap = (name: string): void => { + if (piping) { + traps.push(name); + // Ignored once its work has lost the response + response.setHeader("X-Trap", name); + } + }; + const proxy = new Proxy(source, { + get(target, key, receiver): unknown { + trap(`get ${String(key)}`); + const value: unknown = Reflect.get(target, key, receiver); + if (spoof && key === "on" && typeof value === "function") { + return function on( + this: PassThrough, + event: string, + listener: () => void, + ): unknown { + Reflect.apply(value, this, [event, listener]); + if (event === "data") { + Reflect.apply(value, this, [ + "data", + function ondata(): void { + response.write("OLD"); + }, + ]); + } + return this; + }; + } + return value; + }, + getOwnPropertyDescriptor( + target, + key, + ): PropertyDescriptor | undefined { + trap(`getOwnPropertyDescriptor ${String(key)}`); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + getPrototypeOf(target): object | null { + trap("getPrototypeOf"); + return Reflect.getPrototypeOf(target); + }, + }); + proxy.pipe(response); + }, + ); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + ok(!/OLD|X-Trap/u.test(reply.raw), reply.raw); + // Its traps run only to unpipe it, once its work has lost the response + ok(traps.includes("getPrototypeOf"), JSON.stringify(traps)); + }); + } + + for (const name of [ + "_readableState", + "_events", + "_eventsCount", + "readableEnded", + "on", + ]) { + test(`an accessor \`${name}\` of the source itself (a getter that adds a \`data\` listener)`, async () => { + const { reply } = await refusedBy((source, response) => { + const value: unknown = Reflect.get(source, name); + let armed = true; + Object.defineProperty(source, name, { + configurable: true, + enumerable: true, + get(this: PassThrough): unknown { + // The table itself (its getter would run again) + const table = ( + name === "_events" ? value : Reflect.get(this, "_events") + ) as Record; + const { data } = table; + if (armed && typeof data === "function") { + armed = false; + // After the listener of the pipe, as if it were its own + table.data = [ + data, + (): void => { + response.write("OLD"); + }, + ]; + } + return value; + }, + set(): void { + // Ignored + }, + }); + }); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + ok(!reply.raw.includes("OLD"), reply.raw); + }); + } + + test("a source that cannot be patched (not extensible)", async () => { + const { reply } = await refusedBy((source) => { + Object.preventExtensions(source); + }); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + }); + + for (const [name, prepare] of [ + [ + "a listener of `removeListener` of the source", + (source: PassThrough, response: BindenResponse): void => { + source.on("removeListener", () => { + response.end("OLD"); + }); + }, + ], + [ + "its own `removeListener()`", + (source: PassThrough, response: BindenResponse): void => { + const inherited = Reflect.get( + PassThrough.prototype, + "removeListener", + ) as (...args: unknown[]) => unknown; + Object.defineProperty(source, "removeListener", { + configurable: true, + writable: true, + value(this: PassThrough, ...args: unknown[]): unknown { + response.end("OLD"); + return Reflect.apply(inherited, this, args); + }, + }); + }, + ], + ] as const) { + test(`the cleanup of its unpipe (${name}) runs once its work has lost the response`, async () => { + const { reply, logged } = await refusedBy( + prepare, + piped, + // The stack leaves its own `removeListener()` to Node.js + name === "its own `removeListener()`" ? "handler" : "stack", + ); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [500, "", true], + ); + ok(!reply.raw.includes("OLD"), reply.raw); + deepStrictEqual(logged, ["TypeError"]); + }); + } + + test("the cleanup of its unpipe that throws (from a timer) is logged: the reply is the error", async () => { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const source = silent(); + source.on("removeListener", () => { + throw new Error("The cleanup has failed"); + }); + // Out of the middleware: what it throws would crash + setTimeout(() => { + source.pipe(context.response); + }, 5); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + await captured.waitFor("The cleanup of a refused pipe has failed"); + deepStrictEqual( + captured.records + .filter(({ msg }) => msg === "The request has failed") + .map(({ failure }) => (failure as { type: string }).type), + ["TypeError"], + ); + }, + ); + }); + + test("one of two responses of one source: the other gets its exact body, the source lives", async () => { + const source = silent(); + const feed = feeder(source, ["x", "y"]); + const count = { piped: 0 }; + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + if (context.url.pathname === "/refused") { + context.response.on("newListener", () => null); + } + source.pipe(context.response); + count.piped += 1; + await once(context.response, "finish"); + }); + }, + async ({ port }) => { + const refusedReply = exchange(port, { path: "/refused" }); + const accepted = exchange(port); + await pollUntil(() => count.piped >= 2); + const failed = await refusedReply; + deepStrictEqual([failed.status, failed.body], [500, ""]); + ok(!source.destroyed); + feed.start(); + const reply = await accepted; + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "xy", true], + ); + }, + ); + } finally { + feed.stop(); + } + }); + }, + ); + + suite("of an error handler, destroyed by its own pipe (its failure)", () => { + /** A transform that passes its chunks */ + const passing = (): Transform => + new Transform({ + transform(chunk: Buffer, _encoding, callback): void { + callback(null, chunk); + }, + }); + + /** + * The reply of an error handler that runs `handle`, for a `409` (or only + * for the deadline, with `timeout`, unless `throws`: the stack hangs) + */ + const handled = async ( + handle: (context: Context, error: unknown) => Promise, + options: IBindenOptions = {}, + throws = typeof options.timeout === "undefined", + ): Promise<{ reply: IRaw; captured: ICaptured }> => { + let result: { reply: IRaw; captured: ICaptured } | null = null; + await withApp( + { error_handler_timeout: 2000, ...options, error_handler: handle }, + (app) => { + app.use(async () => { + if (throws) { + throw new BindenError(409); + } + await never(); + }); + }, + async ({ port }, captured) => { + result = { reply: await exchange(port, { linger: 40 }), captured }; + }, + ); + return result as unknown as { reply: IRaw; captured: ICaptured }; + }; + + for (const form of ["pipe", "pipeline", "pipeline of 3 streams"]) { + for (const committed of [false, true]) { + for (const error of [true, false]) { + test(`its ${form}, whose source ${error ? "fails" : "closes"} ${committed ? "after" : "before"} its first chunk: ${committed ? "the connection is closed" : "500"}`, async () => { + const settled = Promise.withResolvers(); + const { reply } = await handled( + async (context, failure) => { + if (!isDeadline(failure)) { + return; + } + context.response.status(503); + const source = new PassThrough(); + const { response } = context; + if (form === "pipe") { + source.pipe(response); + settled.resolve(null); + } else { + const piped = + form === "pipeline" + ? pipelineAsync(source, response) + : pipelineAsync(source, passing(), response); + piped.catch((rejection: unknown) => { + const { code, message } = rejection as Error & { + code?: string; + }; + settled.resolve(code ?? message); + }); + } + // Of a work of no request + owners.exit(() => { + setTimeout(() => { + if (committed) { + source.write("abc"); + } + setTimeout(() => { + if (error) { + source.destroy(new Error("boom")); + } else { + source.destroy(); + } + }, 10); + }, 5); + }); + await never(); + }, + { timeout: 20 }, + ); + if (committed) { + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [503, false, true], + ); + } else { + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [500, true, false], + ); + deepStrictEqual(reply.raw.match(/HTTP\/1\.1/gu)?.length, 1); + } + if (form !== "pipe") { + // The original error + deepStrictEqual( + await within(settled.promise), + error ? "boom" : "ERR_STREAM_PREMATURE_CLOSE", + ); + } + }); + } + } + } + + for (const kind of ["thrown", "deadline"] as const) { + for (const style of ["promise", "callback"] as const) { + for (const streams of [2, 3]) { + for (const error of [true, false]) { + for (const committed of [false, true]) { + test(`its ${style} pipeline of ${streams} streams (${kind === "thrown" ? "a thrown error" : "a deadline"}), whose head ${error ? "fails" : "closes"} from a work of no request ${committed ? "after" : "before"} the first chunk: ${committed ? "the connection is closed" : "500"}`, async () => { + const settled = Promise.withResolvers(); + const { reply, captured } = await handled( + async (context, failure) => { + if (kind === "deadline" && !isDeadline(failure)) { + return; + } + const { response } = context; + response.status(502).type("text/plain"); + const head = new PassThrough(); + const done = (rejection?: unknown): void => { + const { code, message } = (rejection ?? { + message: "resolved", + }) as Error & { code?: string }; + settled.resolve(code ?? message); + }; + const callback = (rejection: Error | null): void => { + done(rejection); + }; + if (style === "promise") { + (streams === 2 + ? pipelineAsync(head, response) + : pipelineAsync(head, passing(), response) + ).then(() => { + done(); + }, done); + } else if (streams === 2) { + pipeline(head, response, callback); + } else { + pipeline(head, passing(), response, callback); + } + // Of a work of no request: the destroy of the response by + // the pipeline runs as that work + owners.exit(() => { + setTimeout(() => { + if (committed) { + head.write("abc"); + } + setTimeout(() => { + if (error) { + head.destroy(new Error("boom")); + } else { + head.destroy(); + } + }, 10); + }, 5); + }); + await settled.promise; + }, + kind === "deadline" ? { timeout: 20 } : {}, + ); + if (committed) { + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [502, false, true], + ); + } else { + deepStrictEqual( + [reply.status, reply.body, reply.complete, reply.closed], + [500, "", true, false], + ); + deepStrictEqual(reply.raw.match(/HTTP\/1\.1/gu)?.length, 1); + // The failure of its pipe + await captured.waitFor( + "The provided `error_handler` has failed", + ); + } + deepStrictEqual( + await within(settled.promise), + error ? "boom" : "ERR_STREAM_PREMATURE_CLOSE", + ); + }); + } + } + } + } + } + + for (const when of ["shutdown", "client"] as const) { + test(`a destroy of no work ${when === "shutdown" ? "from an abort listener of a shutdown (its connection still open)" : "as its client goes away (before the framework knows)"}: not a failure of its pipe`, async () => { + const seen = Promise.withResolvers(); + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + error_handler: async (context: Context): Promise => { + const { request, response } = context; + pipelineAsync(new PassThrough(), passing(), response).catch( + () => null, + ); + const destroy = (): void => { + const alive = !request.socket.destroyed; + owners.exit(() => { + response.destroy(new Error("Terminated")); + }); + seen.resolve([alive, response.destroyed]); + }; + if (when === "shutdown") { + context.signal.addEventListener("abort", destroy); + } else { + request.once("aborted", destroy); + } + await never(); + }, + }).use(() => { + throw new BindenError(409); + }); + const listening = await listen(app); + const socket = connect(listening.port, "127.0.0.1"); + try { + await once(socket, "connect"); + socket.on("error", () => null); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await sleep(30); + if (when === "shutdown") { + const closing = shutdown(listening.server, { + timeout: 20, + cleanup_timeout: 500, + }); + // Destroyed at once, not replied + deepStrictEqual(await within(seen.promise), [true, true]); + await within(closing); + } else { + socket.destroy(); + deepStrictEqual(await within(seen.promise), [false, true]); + } + ok( + !messages(captured, 50).includes( + "The provided `error_handler` has failed", + ), + ); + } finally { + socket.destroy(); + if (listening.server.listening) { + await listening.close(); + } + } + }); + } + + test("a pipeline of the stack whose source closes before the headers: as Node.js, the response is destroyed", async () => { + const settled = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const source = new PassThrough(); + pipelineAsync(source, passing(), context.response).catch( + (error: unknown) => { + settled.resolve((error as { code?: string }).code); + }, + ); + owners.exit(() => { + setTimeout(() => { + source.destroy(); + }, 10); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.closed], [null, true]); + deepStrictEqual( + await within(settled.promise), + "ERR_STREAM_PREMATURE_CLOSE", + ); + }, + ); + }); + + test("its pipeline of a head destroyed before: 500", async () => { + const settled = Promise.withResolvers(); + const { reply } = await handled(async (context) => { + const head = new PassThrough(); + head.destroy(); + pipelineAsync(head, passing(), context.response).catch( + (failure: unknown) => { + settled.resolve((failure as { code?: string }).code); + }, + ); + await never(); + }); + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [500, true, false], + ); + deepStrictEqual( + await within(settled.promise), + "ERR_STREAM_PREMATURE_CLOSE", + ); + }); + + for (const which of ["of a work of no request", "of its own, unpiped"]) { + test(`a destroy while only a pipe ${which} is adopted: the connection is closed`, async () => { + const { reply, captured } = await handled(async (context) => { + const { response } = context; + const source = new PassThrough(); + if (which === "of a work of no request") { + owners.exit(() => { + source.pipe(response); + }); + } else { + source.pipe(response, { end: false }); + source.end(); + await once(source, "end"); + } + await sleep(5); + response.destroy(new Error("Destroyed by the handler")); + await never(); + }); + deepStrictEqual([reply.status, reply.closed], [null, true]); + deepStrictEqual(messages(captured, 50), []); + }); + } + + test("a destroy of the response by the error handler while its pipe is adopted: 500", async () => { + const outcome = Promise.withResolvers(); + const { reply } = await handled(async (context) => { + const { response } = context; + new PassThrough().pipe(response); + finished(response, (error) => { + outcome.resolve(error?.code); + }); + await sleep(5); + response.destroy(); + await never(); + }); + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [500, true, false], + ); + deepStrictEqual( + await within(outcome.promise), + "ERR_STREAM_PREMATURE_CLOSE", + ); + }); + + test("a destroy of the response by the error handler once its pipe has committed the headers: not a failure", async () => { + const { reply, captured } = await handled(async (context) => { + const { response } = context; + const source = new PassThrough(); + source.pipe(response); + source.write("abc"); + await sleep(10); + response.destroy(); + await never(); + }); + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [200, false, true], + ); + deepStrictEqual(messages(captured, 50), []); + }); + + test("a destroy of an error handler that has been superseded changes nothing", async () => { + const { reply, captured } = await handled( + async (context, error) => { + if (isDeadline(error)) { + await sleep(60); + await context.status(503).send("deadline"); + return; + } + const { response } = context; + pipelineAsync(new PassThrough(), passing(), response).catch( + () => null, + ); + // Once the deadline has passed (and its work has been told), with + // a pipe of its own + await sleep(40); + new PassThrough().pipe(response); + response.destroy(); + await never(); + }, + { timeout: 20 }, + true, + ); + deepStrictEqual( + [reply.status, reply.body, reply.complete, reply.closed], + [503, "deadline", true, false], + ); + ok( + !messages(captured, 20).includes( + "A pipe of an abandoned work has failed", + ), + ); + }); + + test("its pipe that the framework destroys (it has not ended its reply): the connection is closed", async () => { + const { reply, captured } = await handled(async (context) => { + // Settles without ending its reply + new PassThrough().pipe(context.response, { end: false }); + await sleep(5); + }); + deepStrictEqual([reply.status, reply.closed], [null, true]); + await captured.waitFor( + "The provided `error_handler` has not ended its reply. Destroying the socket", + ); + }); + + for (const how of ["the client goes away", "a shutdown"]) { + test(`its pipeline once ${how}: the transport closes it, as with Node.js`, async () => { + const settled = Promise.withResolvers(); + const piping = Promise.withResolvers(); + await withApp( + { + error_handler: async (context: Context): Promise => { + const { response } = context; + // Before the listeners of the pipe: while it is adopted + response.on("close", () => { + response.destroy(); + }); + try { + const piped = pipelineAsync( + new PassThrough(), + passing(), + response, + ); + piping.resolve(null); + await piped; + } catch (failure: unknown) { + settled.resolve((failure as { code?: string }).code); + } + }, + }, + (app) => { + app.use(() => { + throw new BindenError(409); + }); + }, + async ({ port, server }, captured) => { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await within(piping.promise); + if (how === "a shutdown") { + await shutdown(server, { timeout: 10 }); + } else { + socket.destroy(); + } + deepStrictEqual( + await within(settled.promise), + "ERR_STREAM_PREMATURE_CLOSE", + ); + await sleep(20); + socket.destroy(); + // Not a failure of the handler (as a failed pipe) + ok( + !messages(captured, 50).includes( + "The provided `error_handler` has failed", + ), + JSON.stringify(captured.records), + ); + }, + ); + }); + } + }); + + suite("transfer once its work is abandoned", () => { + /** + * Reads the response of a request on a connection that is not read for + * `pause` milliseconds (the reply waits under backpressure) + */ + const slowly = async (port: number, pause: number): Promise => { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + socket.pause(); + await sleep(pause); + let raw = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + raw += chunk; + }); + const closed = once(socket, "close"); + socket.resume(); + await within(closed, 10_000); + const end = raw.indexOf("\r\n\r\n"); + const head = raw.slice(0, end); + const [body, complete] = bodyOf(head, raw.slice(end + 4)); + return { raw: "", status: null, head, body, complete, closed: true }; + }; + const size = 8 * 1024 * 1024; + + test("an error handler that has ended its reply, then times out: the whole body is sent", async () => { + await withApp( + { + error_handler_timeout: 50, + error_handler: async (context: Context): Promise => { + context.response.on("close", () => null); + context.response.statusCode = 409; + context.response.end(Buffer.alloc(size, "a")); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(409); + }); + }, + async ({ port }) => { + const reply = await slowly(port, 300); + deepStrictEqual([reply.body.length, reply.complete], [size, true]); + }, + ); + }); + + test("the send of an error handler that has ended its reply settles once flushed, also once it times out", async () => { + const outcomes: unknown[] = []; + await withApp( + { + error_handler_timeout: 50, + error_handler: async (context: Context): Promise => { + context.response.statusCode = 409; + context.send(Buffer.alloc(size, "a")).then( + () => { + outcomes.push("sent"); + }, + (error: unknown) => { + outcomes.push((error as { code?: string }).code); + }, + ); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(409); + }); + }, + async ({ port }) => { + const reply = await slowly(port, 300); + deepStrictEqual([reply.body.length, reply.complete], [size, true]); + await pollUntil(() => outcomes.length > 0).catch(() => null); + deepStrictEqual(outcomes, ["sent"]); + }, + ); + }); + + test( + "the deadline cuts a body that has not been sent in full", + { + // Windows takes a whole write while its send buffer has room: the + // response may finish before the client reads it + skip: process.platform === "win32", + }, + async () => { + await withApp( + { timeout: 50 }, + (app) => { + app.use(async (context: Context) => { + context.response.end(Buffer.alloc(size, "a")); + await never(); + }); + }, + async ({ port }) => { + const reply = await slowly(port, 300); + ok(!reply.complete && reply.body.length < size); + }, + ); + }, + ); + }); + + suite("its failures once the outcome of its work is decided", () => { + test("of the stack that has completed, before the headers: the error handler replies", async () => { + await withApp( + {}, + (app) => { + app.use((context: Context) => { + const source = silent(); + context.response.statusMessage = refused; + source.pipe(context.response); + owners.exit(() => { + setTimeout(() => { + source.write("late"); + }, 10); + }); + // Completed, the response started by the pipe + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + }, + ); + }); + + test("of the stack that has completed, once the headers are sent: the connection is closed", async () => { + await withApp( + {}, + (app) => { + app.use((context: Context) => { + const source = new PassThrough({ objectMode: true }); + context.response.flushHeaders(); + source.pipe(context.response); + owners.exit(() => { + setTimeout(() => { + // Not bytes: its write throws + source.write(42); + }, 10); + }); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.closed], [200, true]); + await captured.waitFor( + "A pipe into the response has failed once its headers were sent. Destroying the socket", + ); + }, + ); + }); + + test("of the stack once its response has finished (a listener of the end of its source throws): logged, with the failure", async () => { + await withApp( + { log_error_details: true }, + (app) => { + app.use(async (context: Context) => { + const source = new PassThrough(); + source.pipe(context.response); + // In the turn of the pipe: a listener of the stack + source.once("end", () => { + throw new Error("A bug in an end listener"); + }); + owners.exit(() => { + setTimeout(() => { + source.end("x"); + }, 10); + }); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "x", true], + ); + const record = await captured.waitFor( + "A pipe into the response has failed once its headers were sent. Destroying the socket", + ); + deepStrictEqual( + [record.level, (record.failure as { message: string }).message], + [50, "A bug in an end listener"], + ); + }, + ); + }); + + test("of the stack once the write that throws has destroyed the response (after its headers): logged, with the failure", async () => { + await withApp( + { log_error_details: true }, + (app) => { + app.use(async (context: Context) => { + const { response } = context; + response.strictContentLength = true; + response.setHeader("Content-Length", "2"); + response.flushHeaders(); + const source = new PassThrough(); + source.pipe(response); + owners.exit(() => { + setTimeout(() => { + // Longer than its `Content-Length` + source.end("abc"); + }, 10); + }); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.closed], [200, true]); + const record = await captured.waitFor( + "A pipe into the response has failed once its headers were sent. Destroying the socket", + ); + deepStrictEqual( + [record.level, (record.failure as { code: string }).code], + [50, "ERR_HTTP_CONTENT_LENGTH_MISMATCH"], + ); + }, + ); + }); + + test("of the stack once the client has gone away: nothing is replied", async () => { + const piped = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.response.flushHeaders(); + await once(context.response, "close"); + // Once the stack has stopped + await sleep(10); + context.response.on("newListener", () => null); + silent().pipe(context.response); + piped.resolve(null); + }); + }, + async ({ port }, captured) => { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await within(once(socket, "data")); + socket.destroy(); + await within(piped.promise); + await sleep(20); + deepStrictEqual(messages(captured, 50), []); + }, + ); + }); + + test("of the stack as the client goes away: nothing is replied", async () => { + const piped = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.response.flushHeaders(); + await once(context.response, "close"); + // Before the stack has stopped + context.response.on("newListener", () => null); + silent().pipe(context.response); + piped.resolve(null); + }); + }, + async ({ port }, captured) => { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await within(once(socket, "data")); + socket.destroy(); + await within(piped.promise); + await sleep(20); + deepStrictEqual(messages(captured, 50), []); + }, + ); + }); + + test("of the stack that has completed, whose error reply fails unexpectedly: the connection is closed", async () => { + await withApp( + {}, + (app) => { + app.use((context: Context) => { + const source = silent(); + // The default reply then fails: it removes the `ETag` of the body + context.setHeader("ETag", `"tag"`); + context.response.removeHeader = (): never => { + throw new Error("Unexpected"); + }; + context.response.statusMessage = refused; + source.pipe(context.response); + owners.exit(() => { + setTimeout(() => { + source.write("late"); + }, 10); + }); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.closed], [null, true]); + await captured.waitFor("The request has failed"); + }, + ); + }); + + for (const work of ["another request", "no request"]) { + test(`refused, of a work of ${work}: unpiped and logged, the response left to its owner (not started)`, async () => { + const source = silent(); + const started: boolean[] = []; + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.response.on("newListener", () => null); + const pipe = (): void => { + source.pipe(context.response); + }; + if (work === "no request") { + owners.exit(pipe); + } else { + owners.run({ name: "a work of another request" }, pipe); + } + // It writes nothing + started.push(context.response.started); + await context.status(202).send("sent"); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.body], [202, "sent"]); + deepStrictEqual(started, [false]); + ok( + !messages(captured, 40).includes( + "The response has already been started", + ), + ); + const { reason } = await captured.waitFor( + "A pipe of a work of no request (or of another) has been refused", + ); + deepStrictEqual( + reason, + "the response has listeners of its listeners", + ); + deepStrictEqual(source.listenerCount("data"), 0); + ok( + !messages(captured, 40).includes( + "A pipe of a work of no request (or of another) cannot be unpiped (e.g. a legacy pipe): it has been left to Node.js", + ), + ); + }, + ); + }); + } + + test("a legacy pipe of a work of no request: it cannot be unpiped, it is left to Node.js and logged", async () => { + const legacy = new Stream() as Stream & { readable: boolean }; + legacy.readable = true; + await withApp( + {}, + (app) => { + app.use(async ({ response }: Context) => { + response.type("text/plain"); + owners.exit(() => { + legacy.pipe(response); + // Its writes are those of the work that feeds it + setTimeout(() => { + legacy.emit("data", Buffer.from("FOREIGN")); + legacy.emit("end"); + }, 5); + }); + await once(response, "finish"); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "FOREIGN", true], + ); + const { reason } = await captured.waitFor( + "A pipe of a work of no request (or of another) cannot be unpiped (e.g. a legacy pipe): it has been left to Node.js", + ); + deepStrictEqual( + reason, + "it is not a pipe of Readable.prototype.pipe()", + ); + ok( + !messages(captured, 40).includes( + "A pipe of a work of no request (or of another) has been refused", + ), + ); + }, + ); + }); + + test("of the stack once the error handler runs: only its own work is abandoned", async () => { + await withApp( + { + error_handler: async (context: Context): Promise => { + await sleep(40); + await context.status(409).send("handler"); + }, + }, + (app) => { + app.use((context: Context) => { + setTimeout(() => { + context.response.on("newListener", () => null); + silent().pipe(context.response); + }, 10); + throw new Error("A failure"); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.body], [409, "handler"]); + await captured.waitFor( + "A pipe of the stack has failed once it had failed", + ); + }, + ); + }); + + test("of an error handler that has settled: only its own work is abandoned", async () => { + await withApp( + { + error_handler: async (context: Context): Promise => { + await context.status(409).send("handler"); + setTimeout(() => { + context.response.on("newListener", () => null); + silent().pipe(context.response); + }, 10); + }, + }, + (app) => { + app.use(() => { + throw new Error("A failure"); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.body], [409, "handler"]); + await captured.waitFor( + "A pipe of the provided `error_handler` has failed late", + ); + }, + ); + }); + + test("a listener of `end` of its source that the abandoned work has added, which throws, is logged", async () => { + const source = silent(); + const added = Promise.withResolvers(); + await withApp( + { timeout: 20 }, + (app) => { + app.use(async (context: Context) => { + source.pipe(context.response); + // In the turn of the pipe: bound to its work + source.once("end", () => { + throw new Error("A listener of the application has failed"); + }); + added.resolve(null); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [503, true]); + await within(added.promise); + source.resume(); + source.end(); + // Once its request has completed + await captured.waitFor( + "A pipe of a work has failed once its request had completed", + ); + }, + ); + }); + + test("a listener of `end` that a completed work has left on a source, which throws, is logged", async () => { + const source = silent(); + const cleaned = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.onCleanup(() => { + cleaned.resolve(null); + }); + source.pipe(context.response); + // In the turn of the pipe: bound to its work + source.once("end", () => { + throw new Error("A listener of the application has failed"); + }); + source.unpipe(context.response); + await context.response.text("ok"); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "ok", true], + ); + await within(cleaned.promise); + source.resume(); + source.end(); + const { level, failure } = await captured.waitFor( + "A pipe of a work has failed once its request had completed", + ); + deepStrictEqual( + [level, (failure as { location: string }).location], + [20, "send"], + ); + ok( + !captured.records.some(({ level: logged }) => Number(logged) > 30), + captured.output(), + ); + }, + ); + }); + + test("whose `end()` throws (from `pipeline()`): its work fails", async () => { + const settled = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.response.statusMessage = refused; + pipeline(Readable.from([]), context.response, (error) => { + settled.resolve(error); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual( + ((await within(settled.promise)) as { code?: string }).code, + "ERR_INVALID_CHAR", + ); + }, + ); + }); + + for (const kind of ["error handler", "stack"]) { + test(`the \`close\` listeners of a failed ${kind} run before the reply that replaces it`, async () => { + const sent: boolean[] = []; + const pipeAndWait = async (context: Context): Promise => { + context.response.on("close", () => { + sent.push(context.response.headersSent); + }); + // Then in a continuation, whose ticks come after it + await sleep(1); + // Refused: its work fails + context.response.on("newListener", () => null); + silent().pipe(context.response); + await never(); + }; + await withApp( + kind === "stack" ? {} : { error_handler: pipeAndWait }, + (app) => { + app.use(async (context: Context) => { + if (kind === "stack") { + await pipeAndWait(context); + } + throw new BindenError(409); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual(sent, [false]); + }, + ); + }); + } + }); + + suite( + "with `on()` and `emit()` of the response wrapped by a middleware (an instrumentation)", + () => { + test("`pipeline()` of an error handler, fed by a work of no request, ends the reply", async () => { + const source = silent(); + const feed = feeder(source, ["REAL", "!"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + const piped = pipelineAsync(source, context.response); + feed.start(); + await piped; + }, + }, + (app) => { + app.use(async (context: Context) => { + instrument(context.response); + await never(); + }); + }, + async ({ port }, captured) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete, reply.closed], + [503, "REAL!", true, false], + ); + deepStrictEqual(messages(captured, 50), []); + }, + ); + } finally { + feed.stop(); + } + }); + + test("a listener that the abandoned work adds to the source writes nothing", async () => { + const source = silent(); + const feed = feeder(source, ["REAL"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + context.response.status(503).type("text/plain"); + source.pipe(context.response); + feed.start(); + await once(context.response, "finish"); + }, + }, + (app) => { + app.use(async (context: Context) => { + instrument(context.response); + context.response.prependListener("pipe", (piped: Readable) => { + piped.on("data", () => { + context.response.write("OLD"); + }); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [503, "REAL", true], + ); + }, + ); + } finally { + feed.stop(); + } + }); + + test("a pipe whose write throws fails its work", async () => { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + instrument(context.response); + context.response.statusMessage = refused; + Readable.from(["body"]).pipe(context.response); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port, { linger: 50 }); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual(reply.raw.match(/HTTP\/1\.1/gu)?.length, 1); + }, + ); + }); + + test("the pipe of the stack is unpiped at its deadline", async () => { + const source = silent(); + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + await context.status(503).send(`${source.listenerCount("data")}`); + }, + }, + (app) => { + app.use(async (context: Context) => { + instrument(context.response); + source.pipe(context.response); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [503, "0", true], + ); + }, + ); + }); + }, + ); + + for (const [version, { PassThrough: Source }] of libraries) { + // A `PassThrough` of `readable-stream` 2 emits its `end` once destroyed: + // the pipe ends the response, as without binden + const ends = version === "2"; + suite(`of \`readable-stream\` ${version}`, () => { + for (const how of ["pipe", "pipeline", "two destinations"]) { + test(`${how}: the whole body`, async () => { + const source = new Source(); + const feed = feeder(source, ["hel", "lo"]); + const other = new Source(); + other.resume(); + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + if (how === "pipeline") { + await pipelineAsync(source, context.response); + return; + } else if (how === "two destinations") { + source.pipe(other); + } + source.pipe(context.response); + feed.start(); + await once(context.response, "finish"); + }); + }, + async ({ port }, captured) => { + if (how === "pipeline") { + feed.start(); + } + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "hello", true], + ); + deepStrictEqual(messages(captured, 50), []); + }, + ); + } finally { + feed.stop(); + } + }); + } + + test("a source that has ended already: an empty reply, ended by its owner", async () => { + const source = new Source(); + source.end(); + source.resume(); + await once(source, "end"); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + source.pipe(context.response); + await once(context.response, "finish"); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "", true], + ); + }, + ); + }); + + test("the deadline during the pipe: the reply of the error handler, and nothing after it", async () => { + const source = new Source(); + const feed = feeder(source, ["OLD", "OLD"]); + try { + await withApp( + { + timeout: 20, + error_handler: async (context: Context, error): Promise => { + if (!isDeadline(error)) { + return; + } + feed.start(); + await sleep(10); + await context.status(503).send("deadline"); + }, + }, + (app) => { + app.use(async (context: Context) => { + source.pipe(context.response); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port, { linger: 60 }); + deepStrictEqual( + [reply.status, reply.body, reply.complete, reply.closed], + [503, "deadline", true, false], + ); + ok(reply.raw.endsWith("\r\n\r\ndeadline"), reply.raw); + }, + ); + } finally { + feed.stop(); + } + }); + + for (const form of ["promise", "callback"]) { + test(`a head refused before the commit (\`pipeline()\`, ${form}): one 500, it settles with the error`, async () => { + const source = new Source(); + const feed = feeder(source, ["body", "more"]); + const settled = Promise.withResolvers(); + const outcome = (error: unknown): void => { + settled.resolve((error as { code?: string } | null)?.code); + }; + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.response.statusMessage = refused; + if (form === "promise") { + pipelineAsync(source, context.response).catch(outcome); + } else { + pipeline(source, context.response, outcome); + } + feed.start(); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port, { linger: 60 }); + deepStrictEqual( + [reply.status, reply.complete, reply.closed], + [500, true, false], + ); + deepStrictEqual(reply.raw.match(/HTTP\/1\.1/gu)?.length, 1); + ok(!reply.raw.includes("body"), reply.raw); + deepStrictEqual( + await within(settled.promise), + "ERR_INVALID_CHAR", + ); + }, + ); + } finally { + feed.stop(); + } + }); + } + + test(`a source of an error handler that closes before its end: ${ends ? "it ends" : "it fails at once"}`, async () => { + const source = new Source(); + await withApp( + { + error_handler: async (context: Context): Promise => { + source.pipe(context.response); + owners.exit(() => { + setTimeout(() => { + source.destroy(); + }, 10); + }); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(409); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.complete], + [ends ? 200 : 500, true], + ); + }, + ); + }); + + test("a guarded source that closes once it has ended does not fail its work", async () => { + // Without a connection: the response never finishes, and the pipe + // stays (Node.js unpipes it once the response has finished) + const response = detached(); + const failures: unknown[] = []; + response[kOwnership]({ + fail: (_owner, error): boolean => { + failures.push(error); + return true; + }, + guards: () => true, + replier: () => null, + native: () => false, + debug: () => null, + warn: () => null, + note: () => null, + }); + const source = new Source(); + owners.run({ name: "an error handler" }, () => { + source.pipe(response); + }); + // Once destroyed (by `readable-stream` 4 itself, once it has ended) + const closed = once(source, "close"); + source.end("ended"); + await once(source, "end"); + source.destroy(); + await within(closed); + deepStrictEqual(failures, []); + }); + + test(`a source of the stack that closes before its end (\`pipeline()\`): ${ends ? "it ends" : "as Node.js, the response is destroyed"}`, async () => { + const source = new Source(); + const settled = Promise.withResolvers(); + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + pipelineAsync(source, context.response).then( + () => { + settled.resolve(null); + }, + (error: unknown) => { + settled.resolve((error as { code?: string }).code); + }, + ); + owners.exit(() => { + setTimeout(() => { + source.destroy(); + }, 10); + }); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.complete || reply.closed], + [ends ? 200 : null, true], + ); + deepStrictEqual( + await within(settled.promise), + ends ? null : "ERR_STREAM_PREMATURE_CLOSE", + ); + }, + ); + }); + + test("with `{ end: false }`, the owner ends the reply itself", async () => { + const source = new Source(); + const feed = feeder(source, ["a", "b"]); + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + source.pipe(context.response, { end: false }); + feed.start(); + await once(source, "end"); + context.response.end("c"); + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "abc", true], + ); + }, + ); + } finally { + feed.stop(); + } + }); + + test("a second, independent destination still gets the source once the work has failed", async () => { + const source = new Source(); + const feed = feeder(source, ["x", "y"]); + const destination = new Source(); + let collected = ""; + destination.on("data", (chunk: Buffer) => { + collected += chunk.toString("latin1"); + }); + const ended = once(destination, "end"); + owners.exit(() => { + source.pipe(destination); + }); + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + context.response.statusMessage = refused; + source.pipe(context.response); + feed.start(); + await never(); + }); + }, + async ({ port }) => { + const reply = await exchange(port, { linger: 60 }); + deepStrictEqual([reply.status, reply.complete], [500, true]); + deepStrictEqual(reply.raw.match(/HTTP\/1\.1/gu)?.length, 1); + await within(ended); + deepStrictEqual(collected, "xy"); + }, + ); + } finally { + feed.stop(); + } + }); + }); + } + + test("through a transform of `through2` 2 (`pipeline()`): the whole body", async () => { + const [, { PassThrough: Source }] = libraries[0] as [ + string, + IReadableStream, + ]; + const source = new Source(); + const feed = feeder(source, ["hel", "lo"]); + try { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + const piped = pipelineAsync( + source, + through2((chunk, _encoding, callback) => { + callback(null, chunk.toString("latin1").toUpperCase()); + }), + context.response, + ); + feed.start(); + await piped; + }); + }, + async ({ port }) => { + const reply = await exchange(port); + deepStrictEqual( + [reply.status, reply.body, reply.complete], + [200, "HELLO", true], + ); + }, + ); + } finally { + feed.stop(); + } + }); + + suite("into a response without the framework (Node.js decides)", () => { + test("refused: it is unpiped, and nothing else", () => { + const response = detached(); + const source = silent(); + response.on("newListener", () => null); + source.pipe(response); + const { pipes } = Reflect.get(source, "_readableState") as { + pipes: unknown[]; + }; + deepStrictEqual([source.listenerCount("data"), pipes.length], [0, 0]); + // A legacy pipe, which has no `unpipe()`: left as it is + const legacy = new Stream(); + legacy.pipe(response); + deepStrictEqual(legacy.listenerCount("data"), 1); + }); + + test("whose write throws: Node.js destroys the response", async () => { + const response = detached(); + response.on("error", () => null); + const source = new PassThrough({ objectMode: true }); + source.pipe(response); + source.write(42); + // Node.js 24.14 emits no `close` without a socket + for (let tick = 0; tick < 10 && !response.destroyed; tick += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); + } + ok(response.destroyed); + }); + + test("whose end throws: it is thrown, as by Node.js", () => { + const response = detached(); + response.statusMessage = refused; + const source = silent(); + source.pipe(response); + let thrown: unknown = null; + try { + source.emit("end"); + } catch (error: unknown) { + thrown = error; + } + deepStrictEqual((thrown as { code?: string }).code, "ERR_INVALID_CHAR"); + }); + + for (const owner of [null, { name: "a work of no framework" }]) { + test(`a listener of \`end\` added in the window that throws: it is thrown (${owner === null ? "no work" : "a work"})`, () => { + const response = detached(); + response[kOwnership]({ + fail: () => false, + guards: () => true, + replier: () => null, + native: () => false, + debug: () => null, + warn: () => null, + note: () => null, + }); + const source = silent(); + const thrown = new Error("Thrown"); + const run = (fn: () => void): void => { + if (owner === null) { + owners.exit(fn); + } else { + owners.run(owner, fn); + } + }; + run(() => { + source.pipe(response); + source.once("end", () => { + throw thrown; + }); + }); + let caught: unknown = null; + try { + source.emit("end"); + } catch (error: unknown) { + caught = error; + } + ok(caught === thrown); + }); + } + + test("of a class whose listeners are not those of `Readable.prototype.pipe()`", () => { + // Its `on()` wraps the `data` listener, its `once()` does not wrap + const on = Reflect.get(PassThrough.prototype, "on") as ( + ...args: unknown[] + ) => unknown; + class Wrapping extends PassThrough {} + class Plain extends PassThrough {} + Object.defineProperty(Wrapping.prototype, "on", { + value(this: PassThrough, event: unknown, listener: () => void) { + const wrapped = Object.assign(listener.bind(this), { listener }); + return Reflect.apply(on, this, [ + event, + event === "data" ? wrapped : listener, + ]); + }, + }); + Object.defineProperty(Plain.prototype, "once", { + value(this: PassThrough, event: unknown, listener: () => void) { + return Reflect.apply(on, this, [event, listener]); + }, + }); + for (const source of [new Wrapping(), new Plain()]) { + const response = detached(); + source.pipe(response); + // Refused: unpiped + deepStrictEqual(source.listenerCount("data"), 0); + } + }); + + test("a method set on the source while its window is open is not a patch of the window", () => { + const first = detached(); + const second = detached(); + const source = silent(); + source.pipe(first); + // Not the patch of the window (which is open until the next tick) + const on = Reflect.get(PassThrough.prototype, "on") as ( + ...args: unknown[] + ) => unknown; + Object.defineProperty(source, "on", { + configurable: true, + writable: true, + value(this: PassThrough, ...args: unknown[]): unknown { + return Reflect.apply(on, this, args); + }, + }); + source.pipe(second); + const { pipes } = Reflect.get(source, "_readableState") as { + pipes: unknown[]; + }; + // The second pipe is refused: unpiped + ok(pipes.length === 1 && pipes[0] === first); + }); + + for (const [count, adopted] of [ + [1, true], + [2, false], + ] as const) { + test(`a state of Node.js 10 with the response as its pipe (${count} pipe${count === 1 ? "" : "s"}): ${adopted ? "adopted" : "refused"}`, () => { + const response = detached(); + const source = Object.assign(new EventEmitter(), { + _readableState: { pipes: response, pipesCount: count }, + }); + source.on("data", () => null).once("end", () => null); + response.emit("pipe", source); + // The patches of its window, once adopted + deepStrictEqual(Object.hasOwn(source, "on"), adopted); + }); + } + + test("a write that throws out of a pipe that does not catch it: Node.js decides (thrown)", () => { + const response = detached(); + const [, { PassThrough: Source }] = libraries[0] as [ + string, + IReadableStream, + ]; + const source = new Source(); + source.pipe(response); + let thrown: unknown = null; + try { + // Not bytes + source.emit("data", 42); + } catch (error: unknown) { + thrown = error; + } + deepStrictEqual( + (thrown as { code?: string }).code, + "ERR_INVALID_ARG_TYPE", + ); + }); + + test("the patches of its window are left once replaced, or removed", async () => { + const response = detached(); + const replaced = silent(); + const removed = silent(); + const own = (): null => null; + replaced.pipe(response); + removed.pipe(response); + Object.defineProperty(replaced, "on", { value: own, configurable: true }); + Reflect.deleteProperty(removed, "on"); + await new Promise((resolve) => { + process.nextTick(resolve); + }); + ok((replaced.on as unknown) === own && !Object.hasOwn(replaced, "once")); + ok(!Object.hasOwn(removed, "on") && !Object.hasOwn(removed, "once")); + }); + }); +}); diff --git a/packages/binden/test/process.test.ts b/packages/binden/test/process.test.ts new file mode 100644 index 00000000..bf0c979c --- /dev/null +++ b/packages/binden/test/process.test.ts @@ -0,0 +1,175 @@ +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 (with the options + * of Node.js, e.g. `--expose-gc`): 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, ...options: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + ...options, + "--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: "", + }); + }); + + test("a file that sendFile() no longer sends is closed, never by the collection of its handle", () => { + // An error handler that fails (also through `Promise.all()`) or times out + // while the file is opened or its metadata read, and a deadline once the + // headers are flushed (which cancels nothing): 3 requests each + deepStrictEqual(run("send-file-release.ts", "--expose-gc"), { + status: 0, + stdout: + "/throws 500 rejected:handler fails streams=0 closed\n" + + "/all 500 rejected:audit fails streams=0 closed\n" + + "/expired 500 rejected:error_handler_timeout streams=0 closed\n" + + "/flushed 200(cut) resolved streams=0 closed\n" + + "fd growth=0 warnings=[]\n", + stderr: "", + }); + }); + + 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("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 Content-Length of another type, set once a send has started and committed around binden, is no length that completes the body as the client goes away: the send rejects, no crash", () => { + deepStrictEqual(run("foreign-length.ts"), { + status: 0, + stdout: "rejected\n", + stderr: "", + }); + }); + + test("a pipe whose write or end throws fails its work: one reply, no crash", () => { + deepStrictEqual(run("pipe-failures.ts"), { + status: 0, + stdout: + "500(open) 500(open) 500(open) 500(open) 500(open) 200(closed) 200(closed)\n" + + "committed-pipeline:ERR_INVALID_ARG_TYPE empty-pipeline:ERR_INVALID_CHAR pipeline:ERR_INVALID_CHAR\n", + stderr: "", + }); + }); + + test("a string whose length Node.js estimates is framed by its bytes: also odd hex on Node.js 24", () => { + // Odd hex, invalid hex and base64, an empty body, any case, a strict + // length: alone, behind another response and after early hints (3 × 10) + deepStrictEqual(run("estimated-lengths.ts"), { + status: 0, + stdout: "30\n", + stderr: "", + }); + }); + + test("a pipe of a work into the response of another request leaves its failures to its own exchange", () => { + deepStrictEqual(run("foreign-pipes.ts"), { + status: 0, + stdout: + "promise+foreign A=500(open): B=200(open):B\n" + + "promise A=500(open): B=200(open):B\n" + + "callback+foreign A=500(open): B=200(open):B\n" + + "callback A=500(open): B=200(open):B\n" + + "callback:ERR_INVALID_CHAR callback:ERR_INVALID_CHAR promise:ERR_INVALID_CHAR promise:ERR_INVALID_CHAR\n", + stderr: "", + }); + }); + + test("a `close` listener that throws surfaces as with Node.js, once the framework has completed the request", () => { + deepStrictEqual(run("throwing-close.ts"), { + status: 0, + stdout: "listener,cleanup A close listener has failed logged:1\n", + stderr: "", + }); + }); + + test("a reason phrase or a header that Node.js refuses does not crash the process", () => { + deepStrictEqual(run("refused-head.ts"), { + status: 0, + stdout: "500 Internal Server Error (10)\n", + stderr: "", + }); + }); + + test( + "SIGTERM shuts the server down gracefully", + // Windows has no SIGTERM handler: `kill("SIGTERM")` ends the process at once + { skip: process.platform === "win32" }, + () => { + deepStrictEqual(run("sigterm.ts"), { + status: 0, + stdout: "200 close done false\n", + stderr: "", + }); + }, + ); + + 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 derived from one of binden, or a proxy of one, logs a failure, and a context whose `log` throws gets its reply: no crash", () => { + deepStrictEqual(run("derived-loggers.ts"), { + status: 0, + stdout: + "derived 500 503 true 1\nproxy 500 503 true 0\nlying 500 503 false 0\n", + stderr: "", + }); + }); + + test("the records of binden go through the private paths of its loggers, and what getting the logger or a field throws drops the record only: every reply arrives, no crash", () => { + deepStrictEqual(run("guarded-records.ts"), { + status: 0, + stdout: + "replaced 200 500 503 200 4 true\n" + + "getter 200 500 503 200 0 false\n" + + "subclass 200 500 503 200 4 false\n" + + "id 200 500 503 200 4 false\n" + + "throwing-child 200 500 503 200 4 true\n" + + "replaced-binden 200 500 503 200 4 true\n" + + "replaced-application 200 500 503 200 4 true\n", + 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: "", + }); + }); +}); 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/cookies.test.ts b/packages/binden/test/property/cookies.test.ts new file mode 100644 index 00000000..f42b7a50 --- /dev/null +++ b/packages/binden/test/property/cookies.test.ts @@ -0,0 +1,204 @@ +import { + BindenRequest, + BindenResponse, + Cookie, + type ICookie, +} from "../../index.js"; +import { anyString, check, token } from "./check.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Socket } from "node:net"; +import fc from "fast-check"; +import { validateHeaderValue } from "node:http"; + +/** 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/headers.test.ts b/packages/binden/test/property/headers.test.ts new file mode 100644 index 00000000..2be36677 --- /dev/null +++ b/packages/binden/test/property/headers.test.ts @@ -0,0 +1,574 @@ +import { + Accept, + AcceptCharset, + AcceptEncoding, + AcceptLanguage, + Authorization, + ContentEncoding, + ContentRange, + ContentType, + Cookie, + Forwarded, + type IAcceptEncodings, + type IAuthenticationType, + type IEncodings, + IfModifiedSince, + Range, +} from "../../index.js"; +import { anyString, check, fieldValueChar, qvalue, token } from "./check.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { quote, split, unquote } from "../../src/headers/list.js"; +import { suite, test } from "node:test"; +import fc from "fast-check"; +import { validateHeaderValue } from "node:http"; + +/** 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 string of any characters: those of field values, control characters, + * line terminators and code points above `\xFF` + */ +const anyText = fc.string({ + unit: fc.oneof( + fieldValueChar, + fc.constantFrom("\0", "\n", "\r", "\x7F", "\u0100", "\u2028", "\u{1F600}"), + ), + 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; + /** Fields of an element, valid or not (see `valid` without them) */ + readonly any?: fc.Arbitrary | undefined; + /** 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 result of a function, or the `TypeError` that it throws */ +function attempt(fn: () => T): T | TypeError { + try { + return fn(); + } catch (error: unknown) { + ok(error instanceof TypeError, String(error)); + return error; + } +} + +/** 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); + validateHeaderValue(name, header.serialize(element)); + deepStrictEqual( + header + .parse(header.serialize(element)) + .map((e) => header.fields(e)), + [header.parsed(header.fields(element))], + ); + }), + ); + }); + + const { any } = header; + if (any) { + test("refuses (`TypeError`) the elements that it cannot serialize, and serializes the others", () => { + check( + fc.property(any, (fields) => { + const element = attempt(() => header.create(fields)); + if (element instanceof TypeError) { + return; + } + validateHeaderValue(name, header.serialize(element)); + deepStrictEqual( + header + .parse(header.serialize(element)) + .map((e) => header.fields(e)), + [header.parsed(header.fields(element))], + ); + }), + 1000, + ); + }); + } + }); +} + +/** 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 media type (`type/subtype`), in any case */ +const mediaType = fc.oneof( + fc.constantFrom( + "multipart/form-data", + "Multipart/Form-Data", + "MULTIPART/FORM-DATA", + "Text/Plain", + ), + fc.tuple(token, token).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 })), + position.map((size) => ({ start: null, end: null, size })), + ), + // `{ size: "*" }` among them, which is not a content range (`bytes *\/*`) + any: fc.record({ + start: fc.option(fc.nat(9)), + end: fc.option(fc.nat(9)), + size: fc.oneof(fc.constant("*" as const), fc.nat(9)), + }), + 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({ + type: mediaType, + charset: fc.option(text()), + boundary: text(1), + parameters, + }), + // A `boundary` of `multipart/form-data` may be missing, and a value may + // have characters that a field value cannot carry + any: fc.record({ + type: mediaType, + charset: fc.option(anyText), + boundary: fc.option(anyText), + parameters: fc.dictionary(token, anyText, { maxKeys: 3 }), + }), + // The media type and the charset in lowercase + parsed: (fields) => { + const charset = fields.charset?.toLowerCase() ?? null; + return { + ...fields, + type: fields.type.toLowerCase(), + 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(), + // Every year of four digits + valid: fc + .date({ + min: new Date("0000-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)], + ); + }, + ), + ); + }); +}); diff --git a/packages/binden/test/property/proxy.test.ts b/packages/binden/test/property/proxy.test.ts new file mode 100644 index 00000000..acddbc3c --- /dev/null +++ b/packages/binden/test/property/proxy.test.ts @@ -0,0 +1,418 @@ +import { + BindenRequest, + type IProxyHeaders, + type IRequestOptions, + type ITrustProxy, + validateRequestOptions, +} from "../../index.js"; +import { anyString, check } from "./check.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import type { IncomingHttpHeaders } from "node:http"; +import { Socket } from "node:net"; +import fc from "fast-check"; + +/** 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(); + } +} + +/** + * The client that a request with these field lines of `Forwarded` tells about, + * when only its peer is trusted + */ +function forwardedBy(lines: readonly string[]): IClient { + class Trusting extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get options(): IRequestOptions { + return { ...validateRequestOptions(), trust_proxy: 1 }; + } + } + const socket = new Socket(); + Object.defineProperty(socket, "remoteAddress", { value: peer }); + try { + const request = new Trusting(socket); + request.url = "/"; + request.method = "GET"; + request.headers = { host: origin, forwarded: lines.join(", ") }; + request.headersDistinct = { host: [origin], forwarded: [...lines] }; + 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("a prefix of the client never hides the element of the trusted peer (`Forwarded`)", () => { + const elements = fc.record({ + n: fc.integer({ min: 1, max: 254 }), + node: fc.constantFrom("{v4}", `"{v4}:8080"`, `"[2001:db8::{n}]"`), + host: fc.constantFrom("h.test", `"h.test:8443"`), + }); + check( + fc.property( + anyString([`"`, "\\", ",", ";", "=", " ", "f", "o", "r", "1", "."]), + elements, + fc.boolean(), + (prefix, { n, node, host }, joined) => { + const value = node + .replace("{v4}", `192.0.2.${n}`) + .replace("{n}", n.toString(16)); + const appended = `for=${value};proto=https;host=${host}`; + // A new field line, or appended to the line of the client + const lines = joined + ? [`${prefix}, ${appended}`] + : [prefix, appended]; + const { ip, protocol, host: effective } = forwardedBy(lines); + const address = value.includes("[") + ? `2001:db8::${n.toString(16)}` + : `192.0.2.${n}`; + deepStrictEqual( + { ip, protocol, host: effective }, + { ip: address, protocol: "https:", host: host.replaceAll(`"`, "") }, + ); + }, + ), + 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/request-target.test.ts b/packages/binden/test/property/request-target.test.ts new file mode 100644 index 00000000..ace2ba41 --- /dev/null +++ b/packages/binden/test/property/request-target.test.ts @@ -0,0 +1,205 @@ +import { + BindenError, + BindenRequest, + type IRequestOptions, + validateRequestOptions, +} from "../../index.js"; +import { check, visible } from "./check.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Socket } from "node:net"; +import fc from "fast-check"; + +/** 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 visibleTarget = fc + .array(fc.oneof(fc.constantFrom(...fragments), fc.constantFrom(...visible)), { + maxLength: 16, + }) + .map((parts) => `/${parts.join("")}`); + +/** A space or a control character */ +const control = fc + .integer({ min: 0, max: 0x21 }) + .map((code) => String.fromCharCode(code === 0x21 ? 0x7f : code)); + +/** + * The path and query of a request-target, as Node.js delivers it, or with a + * space or a control character inserted anywhere (e.g. a rewritten + * `request.url`) + */ +const target = fc.oneof( + visibleTarget, + fc.tuple(visibleTarget, fc.nat(), control).map(([input, at, char]) => { + const index = 1 + (at % input.length); + return `${input.slice(0, index)}${char}${input.slice(index)}`; + }), +); + +/** 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("")), +); + +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` is a `400 Bad Request` for a request-target with a space or a control character", () => { + check( + fc.property(target, fc.boolean(), (input, encoded_slashes) => { + const url = urlOf(input, { encoded_slashes }); + // eslint-disable-next-line no-control-regex -- control characters on purpose + if (/[\x00-\x20\x7F]/u.test(input)) { + ok(url instanceof BindenError && url.status === 400, input); + } + }), + 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, + ); + }); +}); diff --git a/packages/binden/test/property/route.test.ts b/packages/binden/test/property/route.test.ts new file mode 100644 index 00000000..95a2cb1c --- /dev/null +++ b/packages/binden/test/property/route.test.ts @@ -0,0 +1,429 @@ +import { BindenError, type IParams, Stack } from "../../index.js"; +import { compile, matchPrefix, matchRoute } from "../../src/route.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { check } from "./check.js"; +import fc from "fast-check"; + +/** 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, + ); + }); +}); diff --git a/packages/binden/test/proxy.test.ts b/packages/binden/test/proxy.test.ts new file mode 100644 index 00000000..cf6d8a46 --- /dev/null +++ b/packages/binden/test/proxy.test.ts @@ -0,0 +1,715 @@ +import { type AddressInfo, Socket, connect } from "node:net"; +import { + BindenError, + BindenRequest, + type IRequestOptions, + type ITrustProxy, + validateRequestOptions, +} from "../index.js"; +import { type IncomingHttpHeaders, createServer } from "node:http"; +import { after, suite, test } from "node:test"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { TLSSocket } from "node:tls"; + +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 options(): IRequestOptions { + // Not checked: a request checks it too + return { ...validateRequestOptions(), trust_proxy: 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, + ); + // 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: 10_000 }, 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, "127.0.0.1", 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: 10_000 }, 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 options(): IRequestOptions { + return options; + } + } + 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) => { + // Without a host (`::`, unless the machine has no IPv6): the IPv4 peer + // through an IPv6 socket (`::ffff:127.0.0.1`), which this test needs + 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/public-surface.test.ts b/packages/binden/test/public-surface.test.ts new file mode 100644 index 00000000..543b8bcf --- /dev/null +++ b/packages/binden/test/public-surface.test.ts @@ -0,0 +1,172 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { suite, test } from "node:test"; +import { join } from "node:path"; +import { once } from "node:events"; + +/** A finding of `fixtures/public-surface.ts` */ +interface IFinding { + readonly entry: string; + readonly probe: string; + readonly what: string; +} + +/** The report of `fixtures/public-surface.ts` */ +interface IReport { + /** The ids of the entry points that it has enumerated */ + readonly entries: readonly string[]; + /** The ids of the entry points that it has probed (of its shard) */ + readonly probed: readonly string[]; + readonly probes: number; + readonly findings: readonly IFinding[]; +} + +/** How many processes probe the surface in parallel, each a shard of it */ +const shards = 4; +/** How long a shard may run before it is killed */ +const killMs = 110_000; +/** + * How long a shard may run before it stops itself, saying so (derived from + * `killMs`, which stays above it): a run that is too slow is told from one + * that hangs + */ +const timeoutMs = killMs - 10_000; + +/** + * Runs a shard of the fixture of the public surface in a process of its own + * (it sets `Error.prepareStackTrace` and listens for the late failures of the + * process), bounded: it stops itself after `timeoutMs`, and is killed after + * `killMs` + */ +async function survey(shard: number): Promise { + const child = spawn( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "public-surface.ts"), + `--shard=${String(shard)}/${String(shards)}`, + `--timeout=${String(timeoutMs)}`, + ], + { stdio: ["ignore", "pipe", "pipe"], timeout: killMs }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + const [status, signal] = (await once(child, "close")) as [ + number | null, + NodeJS.Signals | null, + ]; + deepStrictEqual( + { shard, status, signal, stderr }, + { shard, status: 0, signal: null, stderr: "" }, + ); + return JSON.parse(stdout) as IReport; +} + +/** + * The reports of every shard, together: every entry point that a shard has + * enumerated is probed by one shard, once + */ +async function surveyed(): Promise { + const reports = await Promise.all( + Array.from({ length: shards }, (_, shard) => survey(shard)), + ); + const entries = new Set(reports.flatMap(({ entries: ids }) => ids)); + const probed = reports.flatMap(({ probed: ids }) => ids); + const unique = new Set(probed); + deepStrictEqual( + { + "probed by no shard": [...entries].filter((id) => !unique.has(id)), + "probed twice": probed.filter( + (id, index) => probed.indexOf(id) !== index, + ), + "probed, not enumerated": [...unique].filter((id) => !entries.has(id)), + }, + { + "probed by no shard": [], + "probed twice": [], + "probed, not enumerated": [], + }, + ); + return { + entries: [...entries], + probed, + probes: reports.reduce((sum, { probes }) => sum + probes, 0), + findings: reports.flatMap(({ findings }) => findings), + }; +} + +suite("The public surface of the packages of binden", () => { + test( + "every entry point (each export, constructor, static and prototype member of every package, also by a symbol, and what they return or install) has a validated baseline, and formats what it throws or rejects with for a hostile value in any argument position (also once a response is sent, ended or destroyed, and in what reads what it stores), passes an error of the application on as itself, reads each option once, examines each position, and refuses without a conversion that is not documented, made as often as documented", + { timeout: killMs + 10_000 }, + async () => { + const { entries, probes, findings } = await surveyed(); + // Not vacuous: every package has entry points, each one probed + for (const name of [ + "binden", + "@binden/logger", + "@binden/body", + "@binden/compression", + "@binden/multipart", + ]) { + ok( + entries.some((id) => id.startsWith(`${name}:`)), + name, + ); + } + ok(probes > entries.length * 5, String(probes)); + deepStrictEqual( + findings.map( + ({ entry, probe, what }) => `${entry} | ${probe}: ${what}`, + ), + [], + ); + }, + ); +}); + +suite( + "A refusal of an entry point that formats what it throws, kept by the application without its stack read, keeps neither the request nor the response", + () => { + for (const [mode, how] of [ + ["cookieNull", "of `new Cookie(null)`"], + ["acceptsSymbol", "of `request.accepts(Symbol())`"], + ["headerNull", "of `request.header(null)`"], + ["errorRevoked", "of `new BindenError(500, revoked)`"], + ["sendFileRevoked", "of `response.sendFile(revoked)`"], + ["setHeaderSymbol", "of `response.setHeader(Symbol())`, by Node.js"], + [ + "compressionEncoding", + "of the encoding of a write through @binden/compression", + ], + ] as const) { + test(how, () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "created-error-retention.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + // 60 requests, whose errors are kept + deepStrictEqual( + { status, stdout, stderr }, + { status: 0, stdout: "collected 60 TypeError\n", stderr: "" }, + ); + }); + } + }, +); diff --git a/packages/binden/test/queued-failures.test.ts b/packages/binden/test/queued-failures.test.ts new file mode 100644 index 00000000..54040869 --- /dev/null +++ b/packages/binden/test/queued-failures.test.ts @@ -0,0 +1,146 @@ +import { Binden, type Context } from "../index.js"; +import { PassThrough, Readable } from "node:stream"; +import { capture, listen, within } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; + +/** How a send has settled: `resolved`, or the code (or the message) of its error */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => { + const { code, message } = error as { code?: unknown; message?: unknown }; + return code ?? message; + }, + ); +} + +/** A source that has errored (a chunk that is not bytes), not destroyed (`autoDestroy: false`) */ +function errored(): Readable { + const source = new Readable({ + autoDestroy: false, + read(): void { + // Never read + }, + }); + source.on("error", () => { + // Its error is `errored` + }); + source.push({}); + return source; +} + +suite( + "A send that fails once the head of its response is committed, while the response waits for the one before it on its connection", + () => { + for (const [what, committed, sourceOf, error] of [ + [ + "a source destroyed before the send", + true, + (): Readable => new PassThrough().destroy(), + "ERR_STREAM_PREMATURE_CLOSE", + ], + [ + "a source that has errored before the send", + true, + errored, + "ERR_INVALID_ARG_TYPE", + ], + [ + "a source that fails before its first chunk", + true, + (): Readable => { + const source = new PassThrough(); + setImmediate(() => source.destroy(new Error("Failed"))); + return source; + }, + "Failed", + ], + [ + "a source that fails once 2 of its 3 bytes are written (not flushed)", + false, + (): Readable => { + const source = new PassThrough(); + source.write("ab"); + setTimeout(() => source.destroy(new Error("Failed")), 5); + return source; + }, + "Failed", + ], + [ + "a body longer than its `Content-Length`", + true, + (): Readable => Readable.from(["abcdef"]), + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + [ + "a source whose `resume()` throws", + true, + (): Readable => { + const source = new (class extends Readable { + // eslint-disable-next-line class-methods-use-this -- it never resumes + public override resume(): this { + throw new Error("Not resumed"); + } + })({ + read(): void { + // Never read + }, + }); + source.pause(); + return source; + }, + "Not resumed", + ], + ] as const) { + test(`${what}${committed ? " (after `flushHeaders()`)" : ""}: its head is never sent, and the next response is not read as its body`, async () => { + const failed = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/slow") { + // Once the send of the response after it has failed + await failed.promise; + await context.text("slow"); + return; + } else if (pathname === "/next") { + await context.text("next"); + return; + } + const { response } = context; + response.setHeader("Content-Length", 3); + if (committed) { + response.flushHeaders(); + } + const outcome = await outcomeOf(context.send(sourceOf())); + failed.resolve([outcome, response.socket === null]); + // A work that catches the failure, and ends the response + response.end(); + }, + ); + const listening = await listen(app); + try { + const { received, closed } = await converse( + listening.port, + `${get("/slow")}${get("/")}${get("/next")}`, + ); + deepStrictEqual( + [await within(failed.promise), closed], + [[error, true], true], + ); + // Only the response before it + deepStrictEqual( + [ + received.match(/HTTP\/1\.1 \d{3}/gu), + received.endsWith("\r\n\r\nslow"), + ], + [["HTTP/1.1 200"], true], + ); + } finally { + await listening.close(); + } + }); + } + }, +); diff --git a/packages/binden/test/redefined-stacks.test.ts b/packages/binden/test/redefined-stacks.test.ts new file mode 100644 index 00000000..478354c1 --- /dev/null +++ b/packages/binden/test/redefined-stacks.test.ts @@ -0,0 +1,299 @@ +import { Binden, type Context } from "../index.js"; +import { capture, ioTurn, listen, send, within } from "./helpers.js"; +import { deepStrictEqual, strictEqual } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { createReadStream } from "node:fs"; +import { readsSafely } from "../src/exchange.js"; + +/** An error that its stack getter would fail to give (see `throwing()`) */ +const unreadable = "Its stack cannot be read"; + +/** Makes the `stack` of an error a getter of the application that throws */ +function throwing(error: Error): void { + Object.defineProperty(error, "stack", { + configurable: true, + get(): never { + throw new Error(unreadable); + }, + }); +} + +/** + * An error whose `name` getter, which V8 reads once it formats its stack, + * runs `redefine` first (e.g. a getter of its `stack` that throws) + */ +function named(redefine: (error: Error) => void): Error { + const error = new Error("Named"); + let redefined = false; + Object.defineProperty(error, "name", { + configurable: true, + get(): string { + if (!redefined) { + redefined = true; + redefine(error); + } + return "Error"; + }, + }); + return error; +} + +/** + * What a send of a source fails with, as the middleware sees it: the value + * that it rejects with, and the records of the logger of the application + */ +async function sent( + source: Readable, +): Promise<{ rejected: unknown; messages: unknown[] }> { + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + await context.send(source).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve(error); + }, + ); + await context.response.status(500).send("failed"); + }, + ); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + strictEqual(status, 500); + const rejected = await within(outcome.promise); + await ioTurn(); + return { rejected, messages: captured.records.map(({ msg }) => msg) }; + } finally { + await listening.close(); + } +} + +/** + * A `Readable` whose own `resume()` (which its `on("data")` calls) throws a + * value once: the send fails with it before anything is sent + */ +function resuming(readable: T, value: unknown): T { + let thrown = false; + readable.resume = function resume(): T { + if (!thrown) { + thrown = true; + throw value; + } + return Readable.prototype.resume.call(this) as T; + }; + return readable; +} + +suite( + "A value whose formatting redefines its stack (H24-1): binden checks it again once formatted", + () => { + test("whose `name` getter defines a getter of its stack, or puts a proxy or another error before the getter of the engine: Node.js cannot read it safely", () => { + const behindProxy = named((error) => { + Reflect.deleteProperty(error, "stack"); + Object.setPrototypeOf(error, new Proxy(Error.prototype, {})); + }); + // The getter of the engine, but of another error + const behindError = named((error) => { + Reflect.deleteProperty(error, "stack"); + Object.setPrototypeOf(error, new Error("Other")); + }); + deepStrictEqual( + [ + readsSafely(named(throwing)), + readsSafely(behindProxy), + readsSafely(behindError), + ], + [false, false, false], + ); + }); + + test("whose stack a `prepareStackTrace` of the application redefines as it formats it: Node.js cannot read it safely", () => { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new Error("Formatted"); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + if (error === formatted) { + throwing(error); + } + return [error.message, ...frames.map(String)].join("\n"); + }, + ); + try { + deepStrictEqual( + [readsSafely(formatted), readsSafely(new Error("Other"))], + [false, true], + ); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + }); + + test("whose `name` getter replaces its stack with a value, or removes it: Node.js reads it safely", () => { + const replaced = named((error) => { + Object.defineProperty(error, "stack", { + configurable: true, + value: "Error: Replaced", + }); + }); + const removed = named((error) => { + Reflect.deleteProperty(error, "stack"); + }); + deepStrictEqual( + [ + readsSafely(replaced), + Reflect.get(replaced, "stack"), + readsSafely(removed), + typeof Reflect.get(removed, "stack"), + readsSafely(new Error("Plain")), + ], + [true, "Error: Replaced", true, "undefined", true], + ); + }); + + test("thrown by the resume() of a Node.js file stream: the send rejects with it, and the stream is destroyed with its carrier, its file closed", async () => { + const value = named(throwing); + const stream = resuming(createReadStream(import.meta.filename), value); + // Not by `once()`, which rejects with its `error` + const closed = new Promise((resolve) => { + stream.once("close", () => { + resolve(null); + }); + }); + const { rejected, messages } = await sent(stream); + await within(closed); + const { errored } = stream as { errored: unknown }; + deepStrictEqual( + [ + rejected === value, + stream.destroyed, + (errored as Error).message, + (errored as Error).cause === value, + stream.closed, + // Closed by its `_destroy()` + Reflect.get(stream, "fd"), + messages.includes("The release of a stream has failed"), + ], + [true, true, "A stream has failed", true, true, null, false], + ); + }); + + test("thrown by the resume() of a Node.js Readable whose own destroy() calls the one of Node.js: it is destroyed once, with the carrier", async () => { + const value = named(throwing); + const stream = resuming( + new Readable({ + read(): void { + // Never reads anything + }, + }), + value, + ); + const destroys: unknown[] = []; + stream.destroy = function destroy(error?: Error): Readable { + destroys.push(error); + return Readable.prototype.destroy.call(this, error); + }; + const { rejected, messages } = await sent(stream); + deepStrictEqual( + [ + rejected === value, + destroys.length, + (destroys[0] as Error | undefined)?.cause === value, + stream.errored === destroys[0], + stream.destroyed, + messages.includes("The release of a stream has failed"), + ], + [true, 1, true, true, true, false], + ); + }); + }, +); + +suite( + "The destroy of a Node.js Readable that fails while it is not destroyed (H24-1, R2-1)", + () => { + test("by the destroy() of Node.js, before it sets `destroyed` (the stack of the value redefined once checked, or code of the stream that throws): it is destroyed by it once more, with the carrier of the value", async () => { + for (const how of ["stale", "throws"] as const) { + const value = new Error("Failed"); + const stream = new Readable({ + read(): void { + // Never reads anything + }, + }); + // Read by the destroy() of Node.js only (before it reads the stack + // of the value, and sets `destroyed`), once armed: once binden has + // checked the value, its stack becomes a getter that throws, or the + // read throws itself + let armed = false; + Object.defineProperty(stream, "_writableState", { + configurable: true, + get(): null { + if (armed) { + armed = false; + if (how === "throws") { + throw new Error(unreadable); + } + throwing(value); + } + // As without a writable side + return null; + }, + }); + resuming(stream, value); + const resume = stream.resume.bind(stream); + stream.resume = (): Readable => { + armed = true; + return resume(); + }; + const { rejected, messages } = await sent(stream); + deepStrictEqual( + [ + rejected === value, + armed, + stream.destroyed, + stream.errored?.message, + stream.errored?.cause === value, + messages.includes("The release of a stream has failed"), + ], + [true, false, true, "A stream has failed", true, false], + how, + ); + } + }); + + test("by a destroy() of its own, which throws before it destroys it: it is called once, the failure logged, and the send rejects with the value", async () => { + const value = new Error("Failed"); + const stream = resuming( + new Readable({ + read(): void { + // Never reads anything + }, + }), + value, + ); + const destroys: unknown[] = []; + stream.destroy = (error?: Error): never => { + destroys.push(error); + throw new Error("Its destroy has failed"); + }; + const { rejected, messages } = await sent(stream); + deepStrictEqual( + [ + rejected === value, + destroys.length, + destroys[0] === value, + stream.destroyed, + messages.includes("The release of a stream has failed"), + ], + [true, 1, true, false, true], + ); + Readable.prototype.destroy.call(stream); + }); + }, +); diff --git a/packages/binden/test/redirect.test.ts b/packages/binden/test/redirect.test.ts new file mode 100644 index 00000000..ecf416da --- /dev/null +++ b/packages/binden/test/redirect.test.ts @@ -0,0 +1,345 @@ +import { BindenResponse, type IRedirectStatus } from "../index.js"; +import { type ITestServer, listen } from "./http.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; +import { redirectLocation } from "../src/redirect.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: The location is not a valid URL"], + ["http://a:99999/", "TypeError: The location is not a valid 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"); + }); +}); diff --git a/packages/binden/test/reemitted-decisions.test.ts b/packages/binden/test/reemitted-decisions.test.ts new file mode 100644 index 00000000..c87ad016 --- /dev/null +++ b/packages/binden/test/reemitted-decisions.test.ts @@ -0,0 +1,109 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { type ICaptured, capture, ioTurn, listen, send } from "./helpers.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; + +/** The message of each late error logged so far */ +function lateOf(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +/** + * A stream-like source that fails without being destroyed, and whose release + * (its `destroy()`, or its `pause()` without one) emits these errors at once + */ +function reemitting( + release: "destroy" | "pause", + emitted: () => readonly Error[], +): EventEmitter { + const source = new EventEmitter(); + let released = false; + const emit = (): void => { + if (!released) { + released = true; + for (const error of emitted()) { + source.emit("error", error); + } + } + }; + return Object.assign(source, { + pause: release === "pause" ? emit : (): null => null, + resume: (): null => null, + ...(release === "destroy" ? { destroy: emit } : {}), + }); +} + +suite( + "A stream-like source whose release emits the error that has failed its send again", + () => { + for (const release of ["destroy", "pause"] as const) { + test(`(its ${release}()): that error is not reported, another one that it emits then is, once (R22-C1)`, async () => { + const failure = new Error("The source has failed"); + const another = new Error("Another"); + const reported: unknown[] = []; + const source = reemitting(release, () => [failure, another, failure]); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + (error) => reported.push(error), + ); + const errors: unknown[] = []; + readable.on("error", (error) => errors.push(error)); + // Nothing reads the readable: no send decides the error for the + // source + source.emit("error", failure); + await ioTurn(); + deepStrictEqual([errors, reported], [[failure], [another]]); + }); + } + + for (const ended of [false, true]) { + test(`sent${ended ? ", once another writer has ended the response" : ""}: ${ended ? "the send resolves, and that error is logged once" : "the send rejects with it, which is not logged"}`, async () => { + const captured = capture(); + const failure = new Error("The source has failed"); + const outcomes: unknown[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const source = reemitting("destroy", () => [failure]); + // @ts-expect-error -- a stream-like source + const sent = context.send(source).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ); + if (ended) { + context.response.end("complete"); + } + source.emit("error", failure); + outcomes.push(await sent); + if (!context.response.writableEnded) { + await context.response.status(500).send("failed"); + } + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + await ioTurn(); + deepStrictEqual( + [status, body, outcomes, lateOf(captured)], + ended + ? [200, "complete", ["resolved"], [failure.message]] + : [500, "failed", [failure.message], []], + ); + } finally { + await listening.close(); + } + }); + } + }, +); diff --git a/packages/binden/test/release-fallbacks.test.ts b/packages/binden/test/release-fallbacks.test.ts new file mode 100644 index 00000000..76732cb3 --- /dev/null +++ b/packages/binden/test/release-fallbacks.test.ts @@ -0,0 +1,252 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { + type ICaptured, + capture, + ioTurn, + listen, + send, + within, +} from "./helpers.js"; +import { deepStrictEqual, strictEqual } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { createReadStream } from "node:fs"; + +/** The message of the failure of a release of a source */ +const releaseFailure = "The release of a stream has failed"; + +/** + * An error whose `stack` getter throws: Node.js reads it to destroy a stream + * with it, and throws before the stream is destroyed + */ +function unstackable(): Error { + return Object.defineProperty(new Error("Unstackable"), "stack", { + get(): never { + throw new Error("Its stack cannot be read"); + }, + }); +} + +/** + * Replies one request with the middleware, which sends the source (and runs + * `meanwhile` with the response): resolves with the status that the client + * gets, the outcome of the send (`resolved`, or the value that it has + * rejected with), and its logger, once the middleware has settled + */ +async function replied( + source: Readable, + meanwhile: (context: Context) => void = (): void => { + // Nothing else + }, +): Promise<[number, unknown, ICaptured]> { + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + meanwhile(context); + await context.send(source).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve(error); + throw error; + }, + ); + }); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + return [status, await within(outcome.promise), captured]; + } finally { + await listening.close(); + } +} + +/** The messages of the records of a release, or of a late error */ +function releases(captured: ICaptured): unknown[] { + return captured.records + .filter(({ msg }) => msg === releaseFailure || msg === releasedFailure) + .map(({ msg }) => msg); +} + +suite("A Readable that binden destroys with what its code has thrown", () => { + for (const [where, thrower] of [ + [ + "its own `resume()`", + (source: Readable, thrown: Error): void => { + source.resume = (): never => { + throw thrown; + }; + }, + ], + [ + "its own `on()`, for the listener of `data`", + (source: Readable, thrown: Error): void => { + const on = Reflect.get(Readable.prototype, "on") as () => unknown; + Object.defineProperty(source, "on", { + value(this: Readable, event: string, listener: () => void): Readable { + if (event === "data") { + throw thrown; + } + Reflect.apply(on, this, [event, listener]); + return this; + }, + }); + }, + ], + ] as const) { + test(`is destroyed (its file closed) with an \`Error\` that carries it as its \`cause\` when Node.js cannot take it as an error (its \`stack\` getter throws), thrown by ${where}: nothing is logged`, async () => { + const thrown = unstackable(); + const source = createReadStream(import.meta.filename); + thrower(source, thrown); + // Not `once()`, which rejects at its `error` + const closed = new Promise((resolve) => { + source.once("close", () => { + resolve(); + }); + }); + const [status, outcome, captured] = await replied(source); + await within(closed); + await ioTurn(); + const { errored } = source as { errored: Error | null }; + deepStrictEqual( + [status, outcome === thrown, source.destroyed, source.closed], + [500, true, true, true], + ); + deepStrictEqual( + [errored?.constructor, errored?.message, errored?.cause === thrown], + [Error, "A stream has failed", true], + ); + // Neither a failure of its release nor a late error: the error that + // it is destroyed with is decided + deepStrictEqual(releases(captured), []); + }); + } + + test("is destroyed with an `Error` that carries it, whose stack is a value of its own before it is handed to the `destroy()` of the source (which may keep it): it holds no frames, and no `prepareStackTrace` runs for it", async () => { + const thrown = unstackable(); + /** The errors whose stacks have been formatted, and when */ + const formatted = new WeakSet(); + const prepare: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + formatted.add(error); + return [ + String(error), + ...frames.map((frame) => ` at ${String(frame)}`), + ].join("\n"); + }, + ); + /** + * The errors that its own `destroy()` gets, whether they are formatted, + * and whether their stack is a value of their own + */ + const destroyed: [unknown, boolean, boolean][] = []; + const source = new (class extends Readable { + public override destroy(error?: Error): this { + if (error === thrown) { + // As Node.js, which reads its stack + throw new Error("Its stack cannot be read"); + } + destroyed.push([ + error, + Object(error) === error && formatted.has(error as object), + "value" in + Object(Reflect.getOwnPropertyDescriptor(Object(error), "stack")), + ]); + return super.destroy(error); + } + })({ + read(): void { + // Never read + }, + }); + source.resume = (): never => { + throw thrown; + }; + try { + const [status, outcome] = await replied(source); + deepStrictEqual([status, outcome === thrown], [500, true]); + } finally { + Reflect.set(Error, "prepareStackTrace", prepare); + } + const [[error, stacked, valued] = []] = destroyed; + deepStrictEqual( + [ + destroyed.length, + (error as Error | undefined)?.cause === thrown, + stacked, + valued, + ], + [1, true, false, true], + ); + }); + + test("whose own `destroy()` throws once it has destroyed it is not destroyed again: what it throws is logged (`debug`)", async () => { + const failure = new Error("Destroyed, then thrown"); + let destroys = 0; + const source = new (class extends Readable { + public override destroy(error?: Error): this { + destroys += 1; + super.destroy(error); + throw failure; + } + })({ + read(): void { + // Never read + }, + }); + source.on("error", () => { + // It is destroyed with what its `resume()` throws + }); + const thrown = new Error("Thrown by its resume()"); + source.resume = (): never => { + throw thrown; + }; + const [status, outcome, captured] = await replied(source); + const record = await captured.waitFor(releaseFailure); + deepStrictEqual( + [status, outcome, destroys, source.errored], + [500, thrown, 1, thrown], + ); + strictEqual( + (record.failure as { message?: unknown }).message, + failure.message, + ); + }); + + test("released without an error by its own `destroy()` that throws (its send resolved once another writer has ended the response) is not destroyed with one: what it throws is logged (`debug`)", async () => { + const failure = new Error("Not destroyed without an error"); + /** The type of the error that each destroy gets, and whether it is destroyed then */ + const destroyed: [string, boolean][] = []; + const source = new (class extends Readable { + public override destroy(error?: Error): this { + destroyed.push([typeof error, this.destroyed]); + throw failure; + } + })({ + read(): void { + // Never read + }, + }); + const [status, outcome, captured] = await replied(source, (context) => { + setImmediate(() => { + context.response.end("other"); + }); + }); + const record = await captured.waitFor(releaseFailure); + deepStrictEqual( + [status, outcome, destroyed, source.destroyed], + [200, "resolved", [["undefined", false]], false], + ); + strictEqual( + (record.failure as { message?: unknown }).message, + failure.message, + ); + }); +}); diff --git a/packages/binden/test/release.test.ts b/packages/binden/test/release.test.ts new file mode 100644 index 00000000..632b5cd2 --- /dev/null +++ b/packages/binden/test/release.test.ts @@ -0,0 +1,600 @@ +import { + Binden, + BindenAbortError, + type Context, + releasedFailure, +} from "../index.js"; +import { capture, listen, pollUntil, send, sleep, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { connect } from "node:net"; +import { join } from "node:path"; +import { once } from "node:events"; +import { spawnSync } from "node:child_process"; + +/** A raw connection to a server */ +interface IRaw { + /** Writes bytes to the server */ + write(text: string): void; + /** + * Resolves with what has been received once it passes the check (or once + * the server has closed the connection), within 10 s + */ + until(check: (data: string) => boolean): Promise; + /** Whether the server has closed the connection */ + readonly closed: boolean; + close(): void; +} + +/** Opens a raw connection to a port */ +async function connection(port: number): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + let closed = false; + socket + .setEncoding("latin1") + .on("data", (chunk: string) => { + data += chunk; + }) + .on("error", () => { + // Closed by the server: `close` follows + }) + .on("close", () => { + closed = true; + }); + return { + write: (text: string): void => { + socket.write(text); + }, + until: async (check: (received: string) => boolean): Promise => { + await pollUntil(() => check(data) || closed).catch(() => { + throw new Error(`Not received: ${JSON.stringify(data)}`); + }); + return data; + }, + get closed(): boolean { + return closed; + }, + close: (): void => { + socket.destroy(); + }, + }; +} + +/** The status lines of the responses that a connection has received, and their bodies (framed by `Content-Length`) */ +function responsesOf(data: string): [string, string][] { + const responses: [string, string][] = []; + let rest = data; + while (rest !== "") { + const end = rest.indexOf("\r\n\r\n"); + ok(end !== -1, `Not a response: ${JSON.stringify(data)}`); + const head = rest.slice(0, end); + const length = Number( + /^Content-Length: (?\d+)$/imu.exec(head)?.groups?.length ?? 0, + ); + const status = head.slice(0, head.indexOf("\r\n")); + const close = /^Connection: close$/imu.test(head) ? " (close)" : ""; + responses.push([ + `${status}${close}`, + rest.slice(end + 4, end + 4 + length), + ]); + rest = rest.slice(end + 4 + length); + } + return responses; +} + +/** How a send has settled: `resolved`, or its error (its `code`, or itself) */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => (error as { code?: unknown }).code ?? error, + ); +} + +suite("A Node.js `Readable` that a response does not send", () => { + test("released by many sends (to `HEAD`, with 204 or 103): one guard, which logs its late error, and no warning", async () => { + const captured = capture(); + const warnings: string[] = []; + const warned = (warning: Error): void => { + warnings.push(warning.name); + }; + process.on("warning", warned); + // Its destroy fails once every send has released it + const failing = Promise.withResolvers(); + const source = new Readable({ + read(): void { + // Nothing to read + }, + destroy(_error, callback): void { + failing.promise.then( + () => { + callback(new Error("The source has failed to close")); + }, + () => null, + ); + }, + }); + const outcomes: unknown[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/no-content") { + context.status(204); + } else if (pathname === "/informational") { + context.status(103); + } + outcomes.push( + await context.response.send(source).then( + () => "resolved", + (error: unknown) => (error as Error).name, + ), + ); + }); + const listening = await listen(app); + try { + const statuses: number[] = []; + for (let index = 0; index < 21; index += 1) { + const [method, path] = ( + [ + ["HEAD", "/"], + ["GET", "/no-content"], + ["GET", "/informational"], + ] as const + )[index % 3] as readonly [string, string]; + statuses.push((await send(listening.port, { method, path })).status); + } + // The send of a 103 rejects, and the middleware replies nothing + deepStrictEqual( + statuses, + Array.from({ length: 7 }, () => [200, 204, 404]).flat(), + ); + deepStrictEqual( + outcomes, + Array.from({ length: 7 }, () => [ + "resolved", + "resolved", + "TypeError", + ]).flat(), + ); + // Released once, by `Readable.prototype.destroy()`: its guard is the + // only listener that the sends have left on it + deepStrictEqual( + [ + source.destroyed, + source.readableFlowing, + source.listenerCount("error"), + source.listenerCount("close"), + ], + [true, null, 1, 1], + ); + failing.resolve(null); + const record = await captured.waitFor(releasedFailure); + // One neutral message, whatever the released stream + deepStrictEqual( + [ + record.msg, + record.level, + (record.failure as { message: string }).message, + ], + ["A released stream has failed", 20, "The source has failed to close"], + ); + // Removed on the turn after its `close` + await pollUntil(() => source.listenerCount("error") === 0); + deepStrictEqual(source.listenerCount("close"), 0); + deepStrictEqual( + captured.records.filter(({ msg }) => msg === releasedFailure).length, + 1, + ); + deepStrictEqual(warnings, []); + } finally { + process.off("warning", warned); + await listening.close(); + } + }); +}); + +suite("The request of a response, sent as its body (an echo)", () => { + test("by a late send, or once the response has ended: ignored, never destroyed (the 503 of the deadline, and the responses pipelined with it)", async () => { + const outcomes: unknown[] = []; + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/slow") { + // The others wait for its response on the connection + await sleep(100); + await context.text("slow"); + return; + } else if (pathname === "/late") { + context.setTimeout(20); + await sleep(60); + outcomes.push(await outcomeOf(context.send(context.request))); + return; + } else if (pathname === "/ended") { + // Ended, and sent once the responses before it have been + const sent = context.text("ended"); + outcomes.push( + await outcomeOf(context.response.send(context.request)), + ); + await sent; + return; + } + await context.text("next"); + }, + ); + const listening = await listen(app); + const raw = await connection(listening.port); + try { + raw.write( + "GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n" + + "POST /late HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello" + + "POST /ended HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello" + + "GET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + const data = await raw.until(() => false); + deepStrictEqual(responsesOf(data), [ + ["HTTP/1.1 200 OK", "slow"], + ["HTTP/1.1 503 Service Unavailable", ""], + ["HTTP/1.1 200 OK", "ended"], + ["HTTP/1.1 200 OK (close)", "next"], + ]); + deepStrictEqual(outcomes, ["resolved", "resolved"]); + } finally { + raw.close(); + await listening.close(); + } + }); + + test("to `HEAD`, with a 204 or a 1xx status: not destroyed (complete replies, without a body, and the connection is kept)", async () => { + const outcomes: unknown[] = []; + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/next") { + await context.text("next"); + return; + } else if (pathname === "/no-content") { + context.status(204); + } else if (pathname === "/informational") { + context.status(103); + } + const outcome = await outcomeOf(context.send(context.request)); + outcomes.push(outcome instanceof TypeError ? "TypeError" : outcome); + if (outcome !== "resolved") { + // Replied (500) + throw outcome; + } + }, + ); + const listening = await listen(app); + const raw = await connection(listening.port); + try { + raw.write( + "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n" + + "GET /no-content HTTP/1.1\r\nHost: localhost\r\n\r\n" + + "POST /no-content HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello" + + "GET /next HTTP/1.1\r\nHost: localhost\r\n\r\n" + + // Its error is replied (the last request of the connection) + "POST /informational HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + ); + const data = await raw.until(() => false); + deepStrictEqual(data.match(/HTTP\/1\.1 [^\r]*/gu), [ + "HTTP/1.1 200 OK", + "HTTP/1.1 204 No Content", + "HTTP/1.1 204 No Content", + "HTTP/1.1 200 OK", + "HTTP/1.1 500 Internal Server Error", + ]); + ok(data.includes("\r\n\r\nnextHTTP/1.1 500 "), data); + deepStrictEqual(outcomes, [ + "resolved", + "resolved", + "resolved", + "TypeError", + ]); + } finally { + raw.close(); + await listening.close(); + } + }); + + test("whose body has not come, cancelled by the deadline: the 503, which closes the connection", async () => { + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger, timeout: 30 }).use( + async (context: Context) => { + outcome.resolve(await outcomeOf(context.send(context.request))); + }, + ); + const listening = await listen(app); + const raw = await connection(listening.port); + try { + raw.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\n", + ); + const data = await raw.until(() => false); + deepStrictEqual(responsesOf(data), [ + ["HTTP/1.1 503 Service Unavailable (close)", ""], + ]); + const reason = await within(outcome.promise); + ok(reason instanceof BindenAbortError, String(reason)); + deepStrictEqual(reason.reason, "deadline"); + } finally { + raw.close(); + await listening.close(); + } + }); + + test("whose body has not come, by an error handler that fails: cancelled, and the 500 closes the connection", async () => { + const outcome = Promise.withResolvers(); + const failure = new Error("The error handler has failed"); + const app = new Binden({ + logger: capture().logger, + error_handler: async (context: Context): Promise => { + outcomeOf(context.send(context.request)).then( + outcome.resolve, + outcome.reject, + ); + await sleep(20); + throw failure; + }, + }).use(() => { + throw new Error("A failure"); + }); + const listening = await listen(app); + const raw = await connection(listening.port); + try { + raw.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\n", + ); + const data = await raw.until(() => false); + deepStrictEqual(responsesOf(data), [ + ["HTTP/1.1 500 Internal Server Error (close)", ""], + ]); + deepStrictEqual(await within(outcome.promise), failure); + } finally { + raw.close(); + await listening.close(); + } + }); + + test("longer than its `Content-Length`, before the headers: the send rejects, and its error is replied on the connection", async () => { + const outcome = Promise.withResolvers(); + const echoed = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + context.response.setHeader("Content-Length", 3); + const error = await outcomeOf(context.send(context.request)); + const { socket } = context.request; + // Nothing of the send is left on it but its guard, which listens + // until the turn after its close (see below) + outcome.resolve([ + error, + socket.destroyed, + context.request.listenerCount("error"), + context.request.listenerCount("data"), + ]); + echoed.resolve(context.request); + throw new Error("Replied"); + }, + ); + const listening = await listen(app); + const raw = await connection(listening.port); + try { + raw.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello", + ); + // The connection is closed after it, as for any error replied before + // the body of the request has been parsed in full + const data = await raw.until(() => false); + deepStrictEqual(responsesOf(data), [ + ["HTTP/1.1 500 Internal Server Error (close)", ""], + ]); + deepStrictEqual(await within(outcome.promise), [ + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + false, + 1, + 0, + ]); + const request = await within(echoed.promise); + await pollUntil(() => request.listenerCount("error") === 0); + ok(request.closed); + } finally { + raw.close(); + await listening.close(); + } + }); + + test("whose error Node.js has scheduled once it has found the listener of the send, without a listener of the application: its guard, which listens before the send removes its own, logs it, and the process does not crash", () => { + for (const [mode, expected] of [ + // A write of the response fails while the request aborts, once its + // connection has closed: the close of the response decides the send + ["write", "ERR_STREAM_PREMATURE_CLOSE 0\n"], + // Its client goes away once the send has failed + ["rst", "ERR_HTTP_CONTENT_LENGTH_MISMATCH 0\n20 ECONNRESET\n"], + ["fin", "ERR_HTTP_CONTENT_LENGTH_MISMATCH 0\n20 ECONNRESET\n"], + ] as const) { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "echo-errors.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual( + { mode, status, stdout, stderr }, + { mode, status: 0, stdout: expected, stderr: "" }, + ); + } + }); + + test("longer than its `Content-Length`, once the headers are committed: the send rejects, and the connection is closed", async () => { + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + context.response.setHeader("Content-Length", 7); + outcome.resolve(await outcomeOf(context.send(context.request))); + }, + ); + const listening = await listen(app); + const raw = await connection(listening.port); + try { + // The rest of its body would not come + raw.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 20\r\n\r\nhello", + ); + // Its first chunk is sent + await raw.until((data) => data.endsWith("\r\n\r\nhello")); + raw.write("world"); + const data = await raw.until(() => false); + ok(raw.closed); + ok(data.startsWith("HTTP/1.1 200 OK\r\n"), data); + ok(data.endsWith("\r\n\r\nhello"), data); + deepStrictEqual( + await within(outcome.promise), + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ); + } finally { + raw.close(); + await listening.close(); + } + }); + + test( + "paused by the backpressure of its send, whose reply another writer ends: resumed (the rest of its body is read, and the next request of the connection answered)", + { + // Windows takes a whole write while its send buffer has room: the + // response is never full, and the send never pauses the request + skip: process.platform === "win32", + }, + async () => { + // Its body is chunked: its client writes until the send has paused it + // (whatever the loopback buffers take, e.g. megabytes on Linux), then + // ends it + const chunk = Buffer.alloc(64 * 1024, "a"); + const frame = Buffer.concat([ + Buffer.from(`${chunk.length.toString(16)}\r\n`), + chunk, + Buffer.from("\r\n"), + ]); + const limit = 256 * 1024 * 1024; + const paused = Promise.withResolvers(); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + const sent = outcomeOf(context.send(context.request)); + // Its client does not read the reply yet + await pollUntil(() => context.request.isPaused()); + paused.resolve(null); + context.response.end(); + outcome.resolve(await sent); + }, + ); + const listening = await listen(app); + const socket = connect(listening.port, "127.0.0.1"); + try { + await once(socket, "connect"); + let tail = ""; + socket + .on("data", (received: Buffer) => { + tail = (tail + received.toString("latin1")).slice(-100); + }) + .on("error", () => { + // Closed by the server: `close` follows + }) + .pause(); + socket.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n", + ); + const state = { paused: false }; + const isPaused = (): boolean => state.paused; + paused.promise.then( + () => { + state.paused = true; + }, + () => null, + ); + let written = 0; + while (!isPaused()) { + ok(written < limit, `Not paused once ${written} bytes are written`); + written += chunk.length; + if (!socket.write(frame)) { + // Until it drains (the server reads), or the request is paused + await within( + Promise.race([ + new Promise((resolve) => { + socket.once("drain", resolve); + }), + paused.promise, + ]), + ); + } + } + socket.resume(); + deepStrictEqual(await within(outcome.promise), "resolved"); + // The rest of its body, which is read + socket.write(frame); + socket.write( + "0\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + await within( + new Promise((resolve) => { + socket.once("close", resolve); + }), + ); + ok(tail.endsWith("\r\n\r\nnext"), tail); + } finally { + socket.destroy(); + await listening.close(); + } + }, + ); + + test("whose reply another writer ends before its body has come: not destroyed (the rest of its body is read, and the connection is kept)", async () => { + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + const sent = outcomeOf(context.send(context.request)); + await sleep(10); + context.response.end("other"); + outcome.resolve(await sent); + }, + ); + const listening = await listen(app); + const raw = await connection(listening.port); + try { + raw.write( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\n", + ); + await raw.until((data) => data.endsWith("\r\n\r\nother")); + deepStrictEqual(await within(outcome.promise), "resolved"); + // Once the response has closed + await sleep(20); + raw.write( + "helloworld" + + "GET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + const data = await raw.until(() => false); + deepStrictEqual(responsesOf(data), [ + ["HTTP/1.1 200 OK", "other"], + ["HTTP/1.1 200 OK (close)", "next"], + ]); + } finally { + raw.close(); + await listening.close(); + } + }); +}); diff --git a/packages/binden/test/released-readables.test.ts b/packages/binden/test/released-readables.test.ts new file mode 100644 index 00000000..de48d0bd --- /dev/null +++ b/packages/binden/test/released-readables.test.ts @@ -0,0 +1,36 @@ +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, with these flags + * of Node.js, without a handler of the process + */ +function fixture(name: string, ...flags: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + ...flags, + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 40_000 }, + ); + return { status, stdout, stderr }; +} + +suite("The readable of a stream-like source, once destroyed", () => { + test("holds nothing of its response (its cancellation reason, its logs): what keeps it and its source, e.g. a pending report of a late error, keeps nothing of the response (R2's note)", () => { + deepStrictEqual(fixture("released-readables.ts", "--expose-gc"), { + status: 0, + stdout: + "cancelled true collected 1\n" + + "failed true collected 1\n" + + "ended true collected 1\n", + stderr: "", + }); + }); +}); diff --git a/packages/binden/test/representation.test.ts b/packages/binden/test/representation.test.ts new file mode 100644 index 00000000..6597a0e4 --- /dev/null +++ b/packages/binden/test/representation.test.ts @@ -0,0 +1,645 @@ +/* eslint-disable init-declarations */ +import { + BindenResponse, + type IRepresentation, + type IRepresentationNegotiator, + type IResponseOptions, + kNegotiator, + kRepresentation, + validateResponseOptions, +} from "../index.js"; +import { type IReply, type ITestServer, listen } from "./http.js"; +import { IncomingMessage, type OutgoingHttpHeaders } from "node:http"; +import { after, afterEach, before, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { kFramework, kSeal } from "../src/seal.js"; +import { mkdtemp, realpath, rm, utimes, writeFile } from "node:fs/promises"; +import { PassThrough } from "node:stream"; +import { Socket } from "node:net"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** 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 }); + } +} + +/** A negotiator that chooses one representation, and records its calls */ +class Negotiator implements IRepresentationNegotiator { + readonly #representation: IRepresentation; + /** The lengths that it has been asked for */ + public readonly lengths: (number | null)[] = []; + /** How many times the choice has been forgotten */ + public forgotten = 0; + + public constructor(representation: IRepresentation) { + this.#representation = representation; + } + + public choose(length: number | null): IRepresentation { + this.lengths.push(length); + return this.#representation; + } + + public forget(): void { + this.forgotten += 1; + } +} + +const coded: IRepresentation = { coding: "gzip", status: null }; +const refused: IRepresentation = { coding: null, status: 406 }; +const identity: IRepresentation = { coding: null, status: null }; + +/** The status, the `ETag`, the `Content-Length` and the body of a reply */ +function summary({ status, headers, body }: IReply): unknown[] { + return [status, headers.etag, headers["content-length"], body.toString()]; +} + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** The strong `ETag` of a body */ +function etagOf(body: string): string { + return `"${createHash("sha256").update(body).digest("base64url")}"`; +} + +suite("The representation chosen before the preconditions", () => { + let tmp: string; + let file: string; + const content = "0123456789"; + const validator = `"v1"`; + // The modification time of the file, and of the dynamic `ETag` + const lm = "Wed, 01 Jan 2020 00:00:00 GMT"; + const before2020 = "Tue, 31 Dec 2019 23:59:59 GMT"; + let server: ITestServer | null = null; + + before(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), "binden-represent-"))); + file = join(tmp, "file.txt"); + await writeFile(file, content); + const date = new Date(lm); + await utimes(file, date, date); + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + afterEach(async () => { + const current = server; + server = null; + await current?.close(); + }); + + /** Serves the next request: `sendFile()` of the file with the representation of a negotiator */ + const sendFile = async ( + negotiator: Negotiator | null, + headers: OutgoingHttpHeaders, + method = "GET", + ): Promise => { + const app = server as ITestServer; + const handled = app.handle(async (_request, response) => { + if (negotiator !== null) { + response[kNegotiator](negotiator); + } + await response.sendFile(file, { validator }); + }); + const reply = await app.request({ method, headers }); + deepStrictEqual(await handled, null); + return reply; + }; + + /** Serves the next request: `send()` of `body` with the dynamic `ETag` */ + const send = async ( + negotiator: Negotiator | null, + headers: OutgoingHttpHeaders, + { method = "GET", modified = null as number | string | null } = {}, + ): Promise => { + const app = server as ITestServer; + const handled = app.handle(async (_request, response) => { + if (negotiator !== null) { + response[kNegotiator](negotiator); + } + if (modified !== null) { + response.setHeader("Last-Modified", modified); + } + await response.send(content); + }); + const reply = await app.request({ method, headers }); + deepStrictEqual(await handled, null); + return reply; + }; + + test("[kRepresentation]()", () => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + // The body as it is without a negotiator + deepStrictEqual(response[kRepresentation](10), identity); + + const negotiator = new Negotiator(coded); + response[kNegotiator](negotiator); + deepStrictEqual(response[kRepresentation](10), coded); + deepStrictEqual(response[kRepresentation](null), coded); + deepStrictEqual(negotiator.lengths, [10, null]); + + // Not for the work that may no longer mutate the response + response[kSeal](); + deepStrictEqual(response[kRepresentation](20), identity); + deepStrictEqual(negotiator.lengths, [10, null]); + }); + + test("sendFile(): the preconditions of a coded representation, whose `ETag` is weak", async () => { + server = await listen(); + for (const method of ["GET", "HEAD"]) { + const negotiator = new Negotiator(coded); + const status = async (headers: OutgoingHttpHeaders): Promise => + (await sendFile(negotiator, headers, method)).status; + const body = method === "GET" ? content : ""; + + // The representation of the whole file, before the preconditions + deepStrictEqual(summary(await sendFile(negotiator, {}, method)), [ + 200, + validator, + `${content.length}`, + body, + ]); + deepStrictEqual(negotiator.lengths, [content.length]); + // `If-Match` compares strongly: the tag of the coded representation is weak + deepStrictEqual( + summary(await sendFile(negotiator, { "If-Match": validator }, method)), + [412, validator, "0", ""], + ); + deepStrictEqual(await status({ "If-Match": `W/${validator}` }), 412); + deepStrictEqual(await status({ "If-Match": "*" }), 200); + deepStrictEqual(await status({ "If-Unmodified-Since": before2020 }), 412); + deepStrictEqual(await status({ "If-Unmodified-Since": lm }), 200); + // `If-None-Match` compares weakly + deepStrictEqual( + summary( + await sendFile(negotiator, { "If-None-Match": validator }, method), + ), + [304, validator, `${content.length}`, ""], + ); + deepStrictEqual(await status({ "If-None-Match": `"other"` }), 200); + deepStrictEqual(await status({ "If-Modified-Since": lm }), 304); + deepStrictEqual(await status({ "If-Modified-Since": before2020 }), 200); + // Each request has chosen once + deepStrictEqual(negotiator.lengths.length, 10); + } + + // Other methods: a matching `If-None-Match` fails + const negotiator = new Negotiator(coded); + deepStrictEqual( + (await sendFile(negotiator, { "If-None-Match": validator }, "POST")) + .status, + 412, + ); + // A coded representation is sent whole: ranges are of the file as it is + for (const headers of [ + { Range: "bytes=0-1" }, + { Range: "bytes=0-1", "If-Range": validator }, + { Range: "bytes=20-30" }, + ]) { + deepStrictEqual(summary(await sendFile(negotiator, headers)), [ + 200, + validator, + `${content.length}`, + content, + ]); + } + + // The file as it is: its strong tag, and its ranges + const plain = new Negotiator(identity); + deepStrictEqual( + (await sendFile(plain, { "If-Match": validator })).status, + 200, + ); + const partial = await sendFile(plain, { + Range: "bytes=0-1", + "If-Range": validator, + }); + deepStrictEqual([partial.status, partial.body.toString()], [206, "01"]); + }); + + test("sendFile(): a replaced representation, whatever the preconditions", async () => { + server = await listen(); + for (const method of ["GET", "HEAD"]) { + for (const headers of [ + {}, + { "If-None-Match": validator }, + { "If-Match": `"other"` }, + { "If-Modified-Since": lm }, + { "If-Unmodified-Since": before2020 }, + { Range: "bytes=0-1" }, + ]) { + const negotiator = new Negotiator(refused); + const reply = await sendFile(negotiator, headers, method); + deepStrictEqual( + [reply.status, reply.headers["content-length"], reply.body.length], + [406, "0", 0], + `${method} ${JSON.stringify(headers)}`, + ); + deepStrictEqual(negotiator.lengths, [content.length]); + } + } + }); + + test("the dynamic `ETag`: the preconditions of the chosen representation", async () => { + server = await listen({ Response: EtagResponse }); + const tag = etagOf(content); + for (const method of ["GET", "HEAD"]) { + const body = method === "GET" ? content : ""; + const length = `${content.length}`; + + // Without a negotiator: the body as it is + const status = async ( + headers: OutgoingHttpHeaders, + modified: number | string | null = null, + ): Promise => + (await send(null, headers, { method, modified })).status; + deepStrictEqual(summary(await send(null, {}, { method })), [ + 200, + tag, + length, + body, + ]); + deepStrictEqual( + summary(await send(null, { "If-Match": `"other"` }, { method })), + [412, tag, "0", ""], + ); + deepStrictEqual(await status({ "If-Match": tag }), 200); + deepStrictEqual(await status({ "If-Match": `"other", ${tag}` }), 200); + deepStrictEqual(await status({ "If-Match": "*" }), 200); + deepStrictEqual(await status({ "If-Match": `W/${tag}` }), 412); + // The dates of the `Last-Modified` of the response + deepStrictEqual(await status({ "If-Unmodified-Since": before2020 }), 200); + deepStrictEqual( + await status({ "If-Unmodified-Since": before2020 }, lm), + 412, + ); + deepStrictEqual(await status({ "If-Unmodified-Since": lm }, lm), 200); + deepStrictEqual( + await status({ "If-Unmodified-Since": before2020 }, "garbage"), + 200, + ); + deepStrictEqual( + await status({ "If-Unmodified-Since": before2020 }, 1), + 200, + ); + // `If-Match` takes precedence over `If-Unmodified-Since` + deepStrictEqual( + await status( + { "If-Match": tag, "If-Unmodified-Since": before2020 }, + lm, + ), + 200, + ); + deepStrictEqual(await status({ "If-Modified-Since": lm }), 200); + deepStrictEqual( + summary( + await send( + null, + { "If-Modified-Since": lm }, + { method, modified: lm }, + ), + ), + [304, tag, length, ""], + ); + deepStrictEqual( + await status({ "If-Modified-Since": before2020 }, lm), + 200, + ); + // `If-None-Match` takes precedence over `If-Modified-Since` + deepStrictEqual( + await status( + { "If-None-Match": `"other"`, "If-Modified-Since": lm }, + lm, + ), + 200, + ); + + // A coded representation: its tag is weak + const negotiator = new Negotiator(coded); + deepStrictEqual( + summary(await send(negotiator, { "If-Match": tag }, { method })), + [412, tag, "0", ""], + ); + deepStrictEqual( + (await send(negotiator, { "If-Match": "*" }, { method })).status, + 200, + ); + deepStrictEqual( + summary(await send(negotiator, { "If-None-Match": tag }, { method })), + [304, tag, length, ""], + ); + deepStrictEqual(negotiator.lengths, Array(3).fill(content.length)); + + // A replaced representation, whatever the preconditions + for (const headers of [ + {}, + { "If-None-Match": tag }, + { "If-Match": `"other"` }, + ]) { + const replaced = await send(new Negotiator(refused), headers, { + method, + }); + deepStrictEqual( + [ + replaced.status, + replaced.headers["content-length"], + replaced.body.length, + ], + [406, "0", 0], + ); + } + } + }); + + test("the `412` of a failed precondition has no `Content-Encoding`, the `304` keeps it", async () => { + /** The status and the coding of a precompressed body, as it is */ + const check = async ( + Response: new (request: IncomingMessage) => BindenResponse, + tag: string, + reply: (response: BindenResponse) => Promise, + ): Promise => { + const app = await listen({ Response }); + try { + for (const method of ["GET", "HEAD"]) { + for (const [headers, status, coding] of [ + [{ "If-Match": `"other"` }, 412, none], + [{ "If-None-Match": tag }, 304, "gzip"], + [{}, 200, "gzip"], + ] as const) { + const handled = app.handle(async (_request, response) => { + response.setHeader("Content-Encoding", "gzip"); + await reply(response); + }); + const received = await app.request({ method, headers }); + deepStrictEqual(await handled, null); + deepStrictEqual( + [received.status, received.headers["content-encoding"]], + [status, coding], + `${method} ${JSON.stringify(headers)}`, + ); + } + } + } finally { + await app.close(); + } + }; + await check(BindenResponse, validator, async (response) => { + await response.sendFile(file, { validator }); + }); + await check(EtagResponse, etagOf(content), async (response) => { + await response.send(content); + }); + }); + + test("the choice is forgotten when its stream fails before its first chunk", async () => { + server = await listen(); + const app = server; + const outcomes: unknown[] = []; + /** Sends a stream that fails once its first chunk is sent (`late`) or before it */ + const fail = async (late: boolean): Promise => { + const negotiator = new Negotiator(coded); + const handled = app.handle(async (_request, response) => { + response[kNegotiator](negotiator); + const source = new PassThrough(); + const sent = response.setHeader("Content-Length", 10).send(source); + if (late) { + // Its first chunk commits the headers + source.write("01234"); + await new Promise((resolve) => { + setImmediate(resolve); + }); + ok(response.headersSent); + } + source.destroy(new Error("Failed")); + outcomes.push( + await sent.then( + () => null, + (error: unknown) => (error as Error).message, + ), + ); + if (!late) { + await response.send("replaced"); + } + }); + const reply = await app.request().catch(() => null); + deepStrictEqual(await handled, null); + if (!late) { + deepStrictEqual(reply?.body.toString(), "replaced"); + } + return negotiator.forgotten; + }; + deepStrictEqual(await fail(false), 1); + deepStrictEqual(await fail(true), 0); + deepStrictEqual(outcomes, ["Failed", "Failed"]); + + // Not by the abandoned work: the reply of the new owner keeps its choice + const negotiator = new Negotiator(coded); + let abandoned: unknown = null; + const handled = app.handle(async (_request, response) => { + response[kNegotiator](negotiator); + const source = new PassThrough(); + const sent = response.send(source); + response[kSeal](); + source.destroy(new Error("Failed")); + abandoned = await sent.then( + () => null, + (error: unknown) => (error as Error).message, + ); + response[kFramework](() => { + response.destroy(); + }); + }); + await app.request().catch(() => null); + deepStrictEqual(await handled, null); + deepStrictEqual(negotiator.forgotten, 0); + deepStrictEqual(abandoned, "Failed"); + }); + + test("the digests: of the content that is sent, and of the representation unless it is replaced (RFC 9530)", async () => { + const digests = { + "Content-Digest": "sha-256=:content:", + "Repr-Digest": "sha-256=:representation:", + Digest: "SHA-256=legacy", + }; + type IReplier = (response: BindenResponse) => Promise; + type IResponseClass = new (request: IncomingMessage) => BindenResponse; + /** Whether a reply keeps the digest of the content, and those of the representation */ + type IKept = readonly [boolean, boolean]; + /** Asserts which digests of the application a reply keeps, field by field */ + const check = async ( + name: string, + Response: IResponseClass, + { method = "GET", headers = {} }: IRequest, + reply: IReplier, + status: number, + [ofContent, ofRepresentation]: IKept, + ): Promise => { + const app = await listen({ Response }); + try { + const handled = app.handle(async (_request, response) => { + response.set(digests); + await reply(response); + }); + const received = await app.request({ method, headers }); + deepStrictEqual(await handled, null, name); + deepStrictEqual(received.status, status, name); + deepStrictEqual( + received.headers["content-digest"], + ofContent ? digests["Content-Digest"] : none, + `${name}: Content-Digest`, + ); + deepStrictEqual( + received.headers["repr-digest"], + ofRepresentation ? digests["Repr-Digest"] : none, + `${name}: Repr-Digest`, + ); + deepStrictEqual( + received.headers.digest, + ofRepresentation ? digests.Digest : none, + `${name}: Digest`, + ); + } finally { + await app.close(); + } + }; + interface IRequest { + readonly method?: string; + readonly headers?: OutgoingHttpHeaders; + } + const all: IKept = [true, true]; + // The content is not sent (or only a part of it): the representation stays + const kept: IKept = [false, true]; + // Another response replaces the representation + const replaced: IKept = [false, false]; + const head = { method: "HEAD" }; + const tag = etagOf(content); + const sendIt: IReplier = async (response) => { + await response.send(content); + }; + const stream: IReplier = (response) => + response + .setHeader("Content-Length", content.length) + .send(PassThrough.from([content])); + const fileIt: IReplier = async (response) => { + await response.sendFile(file, { validator }); + }; + const refuse = + (reply: IReplier): IReplier => + async (response): Promise => { + response[kNegotiator](new Negotiator(refused)); + await reply(response); + }; + + await check("send(): GET", BindenResponse, {}, sendIt, 200, all); + await check("send(): HEAD", BindenResponse, head, sendIt, 200, kept); + const empty: IReplier = async (response) => { + await response.send(); + }; + await check( + "send() of nothing: HEAD", + BindenResponse, + head, + empty, + 200, + kept, + ); + await check("send(stream): GET", BindenResponse, {}, stream, 200, all); + await check("send(stream): HEAD", BindenResponse, head, stream, 200, kept); + for (const code of [204, 205, 304]) { + const withStatus = + (reply: IReplier): IReplier => + (response) => + reply(response.status(code)); + await check( + `send(): ${code}`, + BindenResponse, + {}, + withStatus(sendIt), + code, + kept, + ); + await check( + `send(stream): ${code}`, + BindenResponse, + {}, + withStatus(stream), + code, + kept, + ); + } + await check("dynamic ETag: GET", EtagResponse, {}, sendIt, 200, all); + await check("sendFile(): GET", BindenResponse, {}, fileIt, 200, all); + await check("sendFile(): HEAD", BindenResponse, head, fileIt, 200, kept); + for (const method of ["GET", "HEAD"]) { + const match = { method, headers: { "If-None-Match": tag } }; + const fail = { method, headers: { "If-Match": `"other"` } }; + await check( + `dynamic ETag: ${method} 304`, + EtagResponse, + match, + sendIt, + 304, + kept, + ); + await check( + `dynamic ETag: ${method} 412`, + EtagResponse, + fail, + sendIt, + 412, + replaced, + ); + await check( + `dynamic ETag: ${method} 406`, + EtagResponse, + { method }, + refuse(sendIt), + 406, + replaced, + ); + const fileMatch = { method, headers: { "If-None-Match": validator } }; + await check( + `sendFile(): ${method} 304`, + BindenResponse, + fileMatch, + fileIt, + 304, + kept, + ); + await check( + `sendFile(): ${method} 412`, + BindenResponse, + fail, + fileIt, + 412, + replaced, + ); + await check( + `sendFile(): ${method} 406`, + BindenResponse, + { method }, + refuse(fileIt), + 406, + replaced, + ); + } + const part = { headers: { Range: "bytes=0-1" } }; + await check("sendFile(): 206", BindenResponse, part, fileIt, 206, kept); + const beyond = { headers: { Range: "bytes=100-" } }; + await check( + "sendFile(): 416", + BindenResponse, + beyond, + fileIt, + 416, + replaced, + ); + }); +}); diff --git a/packages/binden/test/request-id.test.ts b/packages/binden/test/request-id.test.ts new file mode 100644 index 00000000..d344d8d5 --- /dev/null +++ b/packages/binden/test/request-id.test.ts @@ -0,0 +1,205 @@ +import { type AddressInfo, Socket, connect } from "node:net"; +import { + BindenRequest, + type IRequestIdOptions, + type IRequestOptions, + validateRequestOptions, +} from "../index.js"; +import { after, suite, test } from "node:test"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { createServer } from "node:http"; + +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: 10_000 }, 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, "127.0.0.1", 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 new file mode 100644 index 00000000..3f12cb2b --- /dev/null +++ b/packages/binden/test/request.test.ts @@ -0,0 +1,721 @@ +/* eslint-disable init-declarations */ +import { type AddressInfo, Socket } from "node:net"; +import { + BindenError, + BindenRequest, + Cookie, + Forwarded, + type IRequestOptions, + type IRequestOptionsInput, + type ITrustProxy, + IfModifiedSince, + Range, + validateRequestOptions, +} from "../index.js"; +import { Server, createServer } from "node:http"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { parse, stringify } from "node:querystring"; +import { TLSSocket } from "node:tls"; +import fetch from "node-fetch"; +import { toError } from "./helpers.js"; + +/** The port of a listening server */ +function portOf(server: { address(): unknown }): number { + return (server.address() as AddressInfo).port; +} + +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; + + beforeEach((_t, done) => { + server = createServer({ IncomingMessage: BindenRequest }).listen( + 0, + "127.0.0.1", + () => { + url = `http://127.0.0.1:${portOf(server)}`; + done(); + }, + ); + }); + + test(".accept_encoding", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(request.accept_encoding.length, 4); + + deepStrictEqual(request.accept_encoding[0].encoding, "*"); + deepStrictEqual(request.accept_encoding[0].q_value, null); + + deepStrictEqual(request.accept_encoding[1].encoding, "x-gzip"); + deepStrictEqual(request.accept_encoding[1].q_value, 0.3); + + deepStrictEqual(request.accept_encoding[2].encoding, "br"); + deepStrictEqual(request.accept_encoding[2].q_value, 0.2); + + deepStrictEqual(request.accept_encoding[3].encoding, "compress"); + deepStrictEqual(request.accept_encoding[3].q_value, 0.15); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url, { + headers: { "Accept-Encoding": "compress;q=0.15,br;q=0.2,x-gzip;q=0.3,*" }, + }); + 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) => { + try { + deepStrictEqual(request.authorization?.type, "Basic"); + deepStrictEqual( + request.authorization.credentials, + "YWxhZGRpbjpvcGVuc2VzYW1l", + ); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url, { + headers: { Authorization: "Basic YWxhZGRpbjpvcGVuc2VzYW1l" }, + }); + 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?.map(({ encoding }) => encoding), + ["x-gzip", "br", "compress"], + ); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url, { + headers: { "Content-Encoding": "compress, br, x-gzip" }, + }); + await serverPromise; + }); + + test(".content_type", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + const { content_type } = request; + ok(content_type); + deepStrictEqual(content_type.charset, null); + deepStrictEqual(content_type.type, "multipart/form-data"); + deepStrictEqual(content_type.boundary, "something"); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url, { + headers: { "Content-Type": "multipart/form-data; boundary=something" }, + }); + await serverPromise; + }); + + test(".id", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + const { id } = request; + ok(id); + ok(typeof id === "string"); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url); + await serverPromise; + }); + + test("secure", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(request.secure, false); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url); + await serverPromise; + }); + + test("protocol", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(request.protocol, "http:"); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url); + await serverPromise; + }); + + test(".query()", async () => { + const query = { a: 1, b: ["2", "3"], c: "4" }; + const expected = { ...parse(stringify(query)) }; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + ok(Array.isArray(request.query.b)); + deepStrictEqual(request.query.b.pop(), "3"); + deepStrictEqual(request.query, expected); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + + const urlQuery = new URL(url); + urlQuery.search = stringify(query); + await fetch(urlQuery.toString()); + await serverPromise; + }); + + test(".body", async () => { + const body = { a: 1 } as Record; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(typeof request.body, "undefined"); + request.body = body; + deepStrictEqual(request.body, body); + request.body = {}; + deepStrictEqual(request.body, body); + body.b = 3; + deepStrictEqual(request.body, body); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + + await fetch(url); + await serverPromise; + }); + + test(".cookies", async () => { + const cookie = new Cookie({ key: "key", value: "value" }); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(request.cookies, [cookie]); + deepStrictEqual(request.cookies[0].key, cookie.key); + deepStrictEqual(request.cookies[0].value, cookie.value); + deepStrictEqual(request.cookies[0].secure, cookie.secure); + deepStrictEqual(request.cookies[0].domain, cookie.domain); + deepStrictEqual(request.cookies[0].expires, cookie.expires); + deepStrictEqual(request.cookies[0].http_only, cookie.http_only); + deepStrictEqual(request.cookies[0].max_age, cookie.max_age); + deepStrictEqual(request.cookies[0].path, cookie.path); + deepStrictEqual(request.cookies[0].same_site, cookie.same_site); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + + await fetch(url, { headers: { Cookie: "key=value" } }); + 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(".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"], + // Control characters and spaces, which the URL parser would remove or + // encode after the checks (e.g. a rewritten `request.url`) + ["/a/.\t./b", "GET", "trusted.test"], + ["/a/%2e\n%2E/b", "GET", "trusted.test"], + ["/a/.\r/b", "GET", "trusted.test"], + ["/a b", "GET", "trusted.test"], + ["/a\0b", "GET", "trusted.test"], + ["/a\x1Fb", "GET", "trusted.test"], + ["/a\x7Fb", "GET", "trusted.test"], + ["/a?b c", "GET", "trusted.test"], + ["/a?b\tc", "GET", "trusted.test"], + ["http://trusted.test/a\tb", "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(".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); + request.url = "http://trusted.test/"; + deepStrictEqual(request.secure, true); + 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(".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(".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 { + // 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); + deepStrictEqual(request.forwarded[0].by, forwarded.by); + deepStrictEqual(request.forwarded[0].secret, forwarded.secret); + deepStrictEqual(request.forwarded[0].host, forwarded.host); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + + await fetch(url, { headers: { Forwarded: "for=8:8:8:8;proto=https" } }); + await serverPromise; + }); + + test(".if_modified_since", async () => { + const ims = new IfModifiedSince({ date: new Date() }); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + ok(request.if_modified_since); + deepStrictEqual( + request.if_modified_since.date, + new Date(ims.date.toUTCString()), + ); + deepStrictEqual(request.if_modified_since.toString(), ims.toString()); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url, { headers: { "If-Modified-Since": ims.toString() } }); + await serverPromise; + }); + + test(".range", async () => { + const range = new Range({ start: 0, end: 499 }); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(request.range.length, 1); + const [actual] = request.range; + ok(actual instanceof Range); + deepStrictEqual(actual.start, range.start); + deepStrictEqual(actual.end, range.end); + deepStrictEqual(actual.toString(), range.toString()); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url, { headers: { Range: range.toString() } }); + await serverPromise; + }); + + test(".URL", async () => { + const newUrl = new URL("/p/a/t/h?a=1&a=2&b=3c", url); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(request.URL, newUrl); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + + await fetch(newUrl.toString()); + await serverPromise; + }); + + test(".header()", async () => { + const value = "some value"; + const headers = { "X-CUSTOM-HEADER": value }; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (request, response) => { + try { + deepStrictEqual(request.header("X-CUSTOM-HEADER"), value); + deepStrictEqual(request.header("x-custom-header"), value); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + + await fetch(url, { headers }); + await serverPromise; + }); + + afterEach((_t, done) => server.close(done)); +}); + +suite("validateRequestOptions()", () => { + test("defaults", () => { + const options = validateRequestOptions(); + deepStrictEqual(options, { + trust_proxy: { trust: false, headers: "forwarded" }, + encoded_slashes: false, + allowed_hosts: null, + request_id: null, + 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("`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, + }); + 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"), + ); + } + }); +}); diff --git a/packages/binden/test/resolved-releases.test.ts b/packages/binden/test/resolved-releases.test.ts new file mode 100644 index 00000000..4aa04a9d --- /dev/null +++ b/packages/binden/test/resolved-releases.test.ts @@ -0,0 +1,104 @@ +import { Binden, type Context } from "../index.js"; +import { capture, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { suite, test } from "node:test"; +import type { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; +import { deepStrictEqual } from "node:assert/strict"; + +const require = createRequire(import.meta.url); + +/** A `PassThrough` of `readable-stream` (a stream-like source) */ +type IPassThrough = EventEmitter & { + readonly destroyed: boolean; + write(chunk: string): unknown; +}; + +/** The `PassThrough` of `readable-stream` 3 or 4 */ +function passThroughOf(version: 3 | 4): IPassThrough { + const { PassThrough } = require(`readable-stream-v${version}`) as { + PassThrough: new () => IPassThrough; + }; + return new PassThrough(); +} + +/** + * Sends a stream-like source whose send resolves (`how`), and resolves with + * what the client gets, how the send has settled, whether the source is + * destroyed, and the errors that a listener of the application has heard + */ +async function resolvedSend( + version: 3 | 4, + how: "ended" | "late chunk" | "length", +): Promise<[string, string, boolean, unknown[]]> { + const source = passThroughOf(version); + const heard: unknown[] = []; + source.on("error", (error: unknown) => { + heard.push((error as { code?: unknown }).code ?? error); + }); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + if (how === "length") { + context.response.setHeader("Content-Length", 3); + } + // @ts-expect-error -- a stream-like source + const sent = context.send(source); + if (how === "length") { + // Its whole body, before its end: complete for the client + source.write("abc"); + } else { + context.response.end("other"); + if (how === "late chunk") { + // Read once the response has ended: no longer sent + source.write("abc"); + } + } + outcome.resolve( + await sent.then( + () => "resolved", + (error: unknown) => `rejected:${String(error)}`, + ), + ); + }, + ); + const listening = await listen(app); + try { + const { received } = await converse(listening.port, get("/"), { + // The client closes once it has the whole response + complete: (text) => text.endsWith(how === "length" ? "abc" : "other"), + }); + const settled = await within(outcome.promise); + await pollUntil(() => source.destroyed); + await ioTurn(); + const body = /\r\n\r\n(?\w*)$/u.exec(received)?.groups?.body ?? ""; + return [body, settled, source.destroyed, heard]; + } finally { + await listening.close(); + } +} + +suite("A stream-like source whose send resolves", () => { + for (const version of [3, 4] as const) { + for (const [how, what] of [ + ["ended", "another writer has ended the response"], + [ + "late chunk", + "another writer has ended the response, before a chunk of it", + ], + [ + "length", + "its declared length is complete, and the client closes before its end", + ], + ] as const) { + test(`(\`readable-stream\` ${version}) is destroyed without an error, as a Node.js \`Readable\`: ${what}`, async () => { + deepStrictEqual(await resolvedSend(version, how), [ + how === "length" ? "abc" : "other", + "resolved", + true, + [], + ]); + }); + } + } +}); diff --git a/packages/binden/test/response-body.test.ts b/packages/binden/test/response-body.test.ts new file mode 100644 index 00000000..a2f7b842 --- /dev/null +++ b/packages/binden/test/response-body.test.ts @@ -0,0 +1,680 @@ +import { + Binden, + BindenResponse, + type Context, + type IResponseOptions, + kNegotiator, + validateResponseOptions, +} from "../index.js"; +import { type IReply, type ITestServer, listen } from "./http.js"; +import { IncomingMessage, type OutgoingHttpHeaders } from "node:http"; +import { PassThrough, Readable } from "node:stream"; +import { Socket, connect } from "node:net"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { listen as listenApp, within } from "./helpers.js"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; + +/** 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 { + return `"${createHash("sha256").update(body).digest("base64url")}"`; +} + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** The settled value of a promise: `null` once resolved, or its error */ +function settle(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + +/** Sends raw requests on one connection, and reads until the server closes it */ +async function raw(url: string, data: string): Promise { + const socket = connect(Number(new URL(url).port), "127.0.0.1"); + await once(socket, "connect"); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + const closed = once(socket, "close"); + socket.write(data); + await within(closed); + return Buffer.concat(chunks).toString("latin1"); +} + +suite("BindenResponse (bodies)", () => { + const servers: ITestServer[] = []; + + const start = async (etag = false): Promise => { + const app = await listen(etag ? { Response: EtagResponse } : {}); + servers.push(app); + return app; + }; + + /** Replies one request with the handler (on a server of its own) */ + const reply = async ( + handler: (response: BindenResponse) => Promise, + options: { method?: string; headers?: OutgoingHttpHeaders } = {}, + etag = false, + ): Promise => { + const app = await start(etag); + const handled = app.handle(async (_request, response) => handler(response)); + const received = await app.request(options); + deepStrictEqual(await handled, null); + return received; + }; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (app) => app.close())); + }); + + test("the declared length of a stream is read once, before its first write", async () => { + // As a compression, which removes the length when it starts + const removing = (response: BindenResponse): void => { + const write = response.write.bind(response); + response.write = (...args: unknown[]): boolean => { + response.removeHeader("Content-Length"); + return Reflect.apply(write, response, args) as boolean; + }; + }; + for (const chunks of [["abc"], ["ab", "cdef"]]) { + const app = await start(); + let error: unknown = null; + const handled = app.handle(async (_request, response) => { + removing(response); + response.setHeader("Content-Length", 4); + error = await settle(response.send(Readable.from(chunks))); + }); + // Cut: never a complete (chunked) body + await rejects(app.request(), (reason: unknown) => { + ok(reason instanceof Error); + return true; + }); + deepStrictEqual(await handled, null); + deepStrictEqual( + (error as { code?: unknown }).code, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ); + } + }); + + test("`send(string, encoding)` encodes the string once: its bytes give the length and the `ETag`", async () => { + const encoded = Buffer.from("x".repeat(120)).toString("base64"); + // Not canonical: line breaks every 76 characters + const base64 = encoded.replaceAll(/(?.{76})/gu, "$\r\n"); + const bytes = Buffer.from(base64, "base64"); + deepStrictEqual(bytes.length, 120); + const received = await reply( + (response) => response.send(base64, "base64"), + {}, + true, + ); + deepStrictEqual(received.headers["content-length"], "120"); + deepStrictEqual(received.headers.etag, etagOf(bytes)); + ok(received.body.equals(bytes)); + + // The next response of the connection is not read as a part of the body + const app = await start(); + app + .handle(async (_request, response) => response.send(base64, "base64")) + .catch(() => null); + app + .handle(async (_request, response) => response.send("next")) + .catch(() => null); + const both = await raw( + app.url, + "GET / HTTP/1.1\r\nHost: a\r\n\r\nGET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + ok(both.endsWith("\r\n\r\nnext"), both); + ok(both.includes(`\r\n\r\n${"x".repeat(120)}HTTP/1.1 200 OK`), both); + + // Invalid hex: what is decoded (nothing) is sent + const hex = await reply((response) => response.send("zz", "hex")); + deepStrictEqual([hex.headers["content-length"], hex.body.length], ["0", 0]); + }); + + test("`GET` and `HEAD` are framed alike, with a `Content-Type` only for a status with a body", async () => { + const cases: [ + string, + (response: BindenResponse) => Promise, + [string | null, string | null], + ][] = [ + ["send()", async (r): Promise => r.send(), ["0", null]], + ['send("")', async (r): Promise => r.send(""), ["0", "text/plain"]], + [ + "send(empty Buffer)", + async (r): Promise => r.send(Buffer.alloc(0)), + ["0", "text/plain"], + ], + [ + "404 send() with a type", + async (r): Promise => r.status(404).type("text/html").send(), + ["0", "text/html"], + ], + [ + "204 send(body)", + async (r): Promise => r.status(204).send("x"), + [null, null], + ], + [ + "304 send(body)", + async (r): Promise => r.status(304).send("x"), + [null, null], + ], + ]; + for (const [name, handler, expected] of cases) { + for (const method of ["GET", "HEAD"]) { + const { headers, body } = await reply(handler, { method }); + deepStrictEqual( + [ + headers["content-length"] ?? null, + headers["content-type"] ?? null, + body.length, + ], + [...expected, 0], + `${method} ${name}`, + ); + ok(!("transfer-encoding" in headers), `${method} ${name}`); + } + } + // A length given to `HEAD` (of the body of `GET`) is kept + for (const [method, length] of [ + ["HEAD", "42"], + ["GET", "0"], + ] as const) { + const { headers } = await reply( + async (r): Promise => r.setHeader("Content-Length", 42).send(), + { method }, + ); + deepStrictEqual(headers["content-length"], length, method); + } + }); + + test("an empty string has the `ETag` of an empty body", async () => { + const empty = etagOf(Buffer.alloc(0)); + for (const body of ["", Buffer.alloc(0)]) { + for (const method of ["GET", "HEAD"]) { + const sent = await reply( + (response) => response.send(body), + { method }, + true, + ); + deepStrictEqual([sent.status, sent.headers.etag], [200, empty], method); + const cached = await reply( + (response) => response.send(body), + { method, headers: { "If-None-Match": "*" } }, + true, + ); + deepStrictEqual( + [ + cached.status, + cached.headers.etag, + cached.headers["content-length"], + ], + [304, empty, "0"], + method, + ); + } + } + }); + + test("a stream that has ended is an empty body, of the declared length", async () => { + const ended = async (): Promise => { + const source = Readable.from(["abc"]); + source.resume(); + await once(source, "end"); + return source; + }; + const source = await ended(); + const received = await reply((response) => response.send(source)); + deepStrictEqual( + [ + received.status, + received.headers["content-length"], + received.body.length, + ], + [200, "0", 0], + ); + + // Not of its length: rejected before anything is sent + let error: unknown = null; + const declared = await ended(); + const failed = await reply(async (response) => { + response.setHeader("Content-Length", 3); + error = await settle(response.send(declared)); + ok(!response.started); + await response.status(500).send(); + }); + deepStrictEqual(failed.status, 500); + deepStrictEqual( + (error as { code?: unknown }).code, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ); + }); + + test("a stream that has been destroyed rejects at once", async () => { + const source = Readable.from(["abc"]); + source.destroy(); + await once(source, "close"); + let error: unknown = null; + const received = await reply(async (response) => { + error = await within(settle(response.send(source)), 1000); + ok(!response.started); + await response.status(500).send(); + }); + deepStrictEqual(received.status, 500); + deepStrictEqual( + (error as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + }); + + test("a stream that fails before its first chunk leaves no framing, unless the response is framed since", async () => { + const range = "bytes 0-9/20"; + const changes: [string, (response: BindenResponse) => void, unknown[]][] = [ + [ + "nothing", + (): void => { + // The framing of the send stays + }, + [none, none], + ], + // Of another header: the framing is still the one of the send + [ + "another header", + (response): void => { + response.setHeader("X-Other", "1"); + }, + [none, none], + ], + [ + "a length set", + (response): void => { + response.setHeader("Content-Length", 4); + }, + [4, range], + ], + [ + "a range removed", + (response): void => { + response.removeHeader("Content-Range"); + }, + [10, none], + ], + [ + "a transfer coding set", + (response): void => { + response.setHeader("Transfer-Encoding", "chunked"); + }, + [10, range], + ], + [ + // Framed as if it had never been attempted + "a head that Node.js refuses", + (response): void => { + throws( + () => + response.writeHead(206, { "Content-Length": 10, Trailer: "X-T" }), + { code: "ERR_HTTP_TRAILER_INVALID" }, + ); + }, + [none, none], + ], + [ + "a range appended", + (response): void => { + response.appendHeader("Content-Range", range); + }, + [10, [range, range]], + ], + // Set again, to the same value: framed since all the same + [ + "the same length set again", + (response): void => { + response.setHeader("Content-Length", 10); + }, + [10, range], + ], + // Refused by Node.js: nothing has changed + [ + "a length that Node.js refuses", + (response): void => { + throws(() => response.setHeader("Content-Length", none as never), { + code: "ERR_HTTP_INVALID_HEADER_VALUE", + }); + }, + [none, none], + ], + [ + "a length appended that Node.js refuses", + (response): void => { + throws(() => response.appendHeader("Content-Length", none as never), { + code: "ERR_HTTP_INVALID_HEADER_VALUE", + }); + }, + [none, none], + ], + ]; + for (const [name, change, expected] of changes) { + let framing: unknown[] = []; + let error: unknown = null; + const received = await reply(async (response) => { + const source = new PassThrough(); + response + .set({ "Content-Length": 10, "Content-Range": range }) + .status(206); + const sent = settle(response.send(source)); + change(response); + source.destroy(new Error("Failed")); + error = await within(sent, 1000); + framing = [ + response.getHeader("Content-Length"), + response.getHeader("Content-Range"), + ]; + response.removeHeader("Content-Length"); + response.removeHeader("Content-Range"); + await response.status(500).send("failed"); + }); + deepStrictEqual(framing, expected, name); + deepStrictEqual((error as Error).message, "Failed", name); + deepStrictEqual( + [received.status, received.body.toString()], + [500, "failed"], + name, + ); + } + }); + + test("a 1xx status is not sent as the final response", async () => { + const message = new TypeError( + "Status code 103 is informational (use writeEarlyHints())", + ); + const response = new BindenResponse(new IncomingMessage(new Socket())); + response.status(103); + await rejects(response.send("data"), message); + await rejects(response.json({ a: 1 }), message); + const source = Readable.from(["data"]); + await rejects(response.send(source), message); + // Released + ok(source.destroyed); + ok(!response.started); + ok(!response.headersSent); + }); + + test("a reply without a body has no `Trailer`, which Node.js refuses without chunks", async () => { + const trailer = (response: BindenResponse): BindenResponse => + response.setHeader("Trailer", "X-T"); + const stream = (): Readable => Readable.from(["abc"]); + const file = import.meta.filename; + /** Replies, the method of their request, and the status and the body that they get */ + const cases: [ + string, + (response: BindenResponse) => Promise, + string, + number, + string, + ][] = [ + // Chunked: with its trailers + [ + "stream", + async (r): Promise => { + trailer(r).addTrailers({ "X-T": "1" }); + await r.send(stream()); + }, + "GET", + 200, + "abc", + ], + [ + "stream", + async (r): Promise => trailer(r).send(stream()), + "HEAD", + 200, + "", + ], + [ + "empty", + async (r): Promise => trailer(r).send(), + "HEAD", + 200, + "", + ], + [ + "bytes", + async (r): Promise => trailer(r).send("abc"), + "HEAD", + 200, + "", + ], + [ + "304", + async (r): Promise => trailer(r).status(304).send(), + "GET", + 304, + "", + ], + [ + "204 stream", + async (r): Promise => trailer(r).status(204).send(stream()), + "GET", + 204, + "", + ], + [ + "205", + async (r): Promise => trailer(r).status(205).send("abc"), + "GET", + 205, + "", + ], + [ + "file", + async (r): Promise => trailer(r).sendFile(file), + "HEAD", + 200, + "", + ], + [ + "204 file", + async (r): Promise => trailer(r).status(204).sendFile(file), + "GET", + 204, + "", + ], + ]; + for (const [name, handler, method, status, body] of cases) { + const received = await reply(handler, { method }); + const key = `${method} ${name}`; + deepStrictEqual( + [received.status, received.body.toString()], + [status, body], + key, + ); + deepStrictEqual( + received.headers.trailer, + status === 200 && method === "GET" ? "X-T" : none, + key, + ); + } + // The `304` of the dynamic `ETag`, and of a file + const headers = { "If-None-Match": "*" }; + const notModified = [ + await reply( + async (r): Promise => trailer(r).send("abc"), + { headers }, + true, + ), + await reply(async (r): Promise => trailer(r).sendFile(file), { + headers, + }), + ]; + for (const received of notModified) { + deepStrictEqual([received.status, received.headers.trailer], [304, none]); + } + }); + + test("an empty reply that replaces the body (`412`, `416`, `406`) has no `Trailer` either", async () => { + const trailer = (response: BindenResponse): BindenResponse => + response.setHeader("Trailer", "X-T"); + /** A library that replaces the representation (e.g. a compression) */ + const refusing = (response: BindenResponse): BindenResponse => { + response[kNegotiator]({ + choose: () => ({ coding: null, status: 406 }), + forget: () => null, + }); + return response; + }; + const file = import.meta.filename; + const failing = { "If-Match": '"other"' }; + /** Replies, their request headers, whether with the dynamic `ETag`, and the status that they get */ + const cases: [ + string, + (response: BindenResponse) => Promise, + OutgoingHttpHeaders, + boolean, + number, + ][] = [ + [ + "a failed precondition of the dynamic `ETag`", + async (r): Promise => trailer(r).send("abc"), + failing, + true, + 412, + ], + [ + "a representation replaced for the dynamic `ETag`", + async (r): Promise => refusing(trailer(r)).send("abc"), + {}, + true, + 406, + ], + [ + "a failed precondition of a file", + async (r): Promise => trailer(r).sendFile(file), + failing, + false, + 412, + ], + [ + "a representation of a file replaced", + async (r): Promise => refusing(trailer(r)).sendFile(file), + {}, + false, + 406, + ], + [ + "a range of a file that cannot be satisfied", + async (r): Promise => trailer(r).sendFile(file), + { Range: "bytes=999999999-" }, + false, + 416, + ], + ]; + for (const [name, handler, headers, etag, status] of cases) { + const received = await reply(handler, { headers }, etag); + deepStrictEqual( + [received.status, received.headers.trailer, received.body.toString()], + [status, none, ""], + name, + ); + } + }); + + test(".type() accepts only parameters of a token and a token or a quoted string", () => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + for (const [valid, expected] of [ + ["text/plain;", "text/plain"], + ['text/plain; a="b c"; d=e', 'text/plain; a="b c"; d=e'], + ['text/plain ;\ta="\\"q\\""', 'text/plain; a="\\"q\\""'], + ]) { + deepStrictEqual(response.type(valid).getHeader("Content-Type"), expected); + } + for (const invalid of [ + "text/plain; charset", + "text/plain; a=b c", + "text/plain; =x", + 'text/plain; a="unterminated', + "text/plain; a=b;c", + "text/plain; a=(b)", + "text/plain, text/html", + 42, + ]) { + throws( + () => response.type(invalid as string), + new TypeError(`Invalid media type: ${String(invalid)}`), + ); + } + }); +}); + +suite("The framing of a send that fails", () => { + test("is removed after a setter that Node.js refuses: the error reply has its own length", async () => { + const setters: Record unknown> = { + set: (response) => response.setHeader("Content-Length", none as never), + append: (response) => + response.appendHeader("Content-Length", none as never), + }; + const refused: unknown[] = []; + const app = new Binden({ + error_handler: ({ response }: Context): void => { + response.end("ERR"); + }, + }).use(async ({ request, response }: Context) => { + const setter = setters[(request.url ?? "/").slice(1)]; + if (typeof setter === "undefined") { + response.end("next"); + return; + } + const source = new PassThrough(); + response.setHeader("Content-Length", 10); + const pending = response.send(source); + try { + setter(response); + } catch (error: unknown) { + refused.push((error as { code?: unknown }).code); + } + source.destroy(); + await pending; + }); + const listening = await listenApp(app); + try { + for (const name of Object.keys(setters)) { + const data = await raw( + listening.url, + `GET /${name} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const [failed = "", next = "", ...rest] = data + .replaceAll(/\r\nDate: [^\r]*/gu, "") + .split(/(?=HTTP\/1\.1 )/u); + deepStrictEqual( + [failed, rest], + [ + // Removed by the framework (see `removeHeader()`): chunked + "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nERR\r\n0\r\n\r\n", + [], + ], + name, + ); + ok(next.startsWith("HTTP/1.1 200 OK\r\n"), `${name}: ${next}`); + ok(next.endsWith("\r\n\r\nnext"), `${name}: ${next}`); + } + deepStrictEqual(refused, [ + "ERR_HTTP_INVALID_HEADER_VALUE", + "ERR_HTTP_INVALID_HEADER_VALUE", + ]); + } finally { + await listening.close(); + } + }); +}); diff --git a/packages/binden/test/response-commit.test.ts b/packages/binden/test/response-commit.test.ts new file mode 100644 index 00000000..cf6ab0c0 --- /dev/null +++ b/packages/binden/test/response-commit.test.ts @@ -0,0 +1,790 @@ +import { + BindenResponse, + Cookie, + type IResponseOptions, + ct_text, + validateResponseOptions, +} from "../index.js"; +import { type IListenOptions, type ITestServer, listen } from "./http.js"; +import { + IncomingMessage, + type OutgoingHttpHeader, + type OutgoingHttpHeaders, + get, + request, +} from "node:http"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; +import { kSeal, kValidateHead } from "../src/seal.js"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; +import { createHash } from "node:crypto"; + +/** 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, + ); +} + +/** 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; + +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" }, + ], + // The alias of Node.js 24, on every version + [ + "writeHeader()", + (r): unknown => r.writeHeader(200, "Fine", { "X-A": "1" }), + cookies, + { "x-a": "1" }, + ], + [ + "writeHeader() without a reason", + (r): unknown => r.writeHeader(200), + cookies, + {}, + ], + ]; + 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"); + // Refused by Node.js once the cookies have been added + throws(() => response.writeHead(99), { + code: "ERR_HTTP_INVALID_STATUS_CODE", + }); + 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()]); + }); + + test(".setTimeout() sets the timeout of the socket, unless the response is sealed", () => { + const response = detached(); + const listeners = (): number[] => + ["timeout", "socket"].map((event) => response.listenerCount(event)); + const timedOut = (): void => { + // Not called + }; + deepStrictEqual(response.setTimeout(1000, timedOut), response); + // Set on its socket once it has one + deepStrictEqual(listeners(), [1, 1]); + response[kSeal](); + deepStrictEqual(response.setTimeout(1, timedOut), response); + deepStrictEqual(listeners(), [1, 1]); + }); + + test("a `writeHead()` that Node.js refuses leaves the response unchanged", () => { + const response = detached(); + response.statusCode = 201; + response.statusMessage = "Made"; + response.setHeader("X-Set", ["a", "b"]); + const state = (): unknown[] => [ + response.statusCode, + response.statusMessage, + response.getHeaders(), + response.headersSent, + ]; + const before = state(); + for (const [name, refused, code] of [ + [ + "reason", + (): unknown => response.writeHead(200, "a\nb"), + "ERR_INVALID_CHAR", + ], + [ + "value", + (): unknown => response.writeHead(200, "Fine", { "X-Set": "\r" }), + "ERR_INVALID_CHAR", + ], + [ + "value of a list", + (): unknown => response.writeHead(200, ["X-Set", ["a", "\n"]]), + "ERR_INVALID_CHAR", + ], + [ + "name", + (): unknown => response.writeHead(200, { "X Set": "a" }), + "ERR_INVALID_HTTP_TOKEN", + ], + [ + "nested pairs", + (): unknown => response.writeHead(200, [["X-Set", none]] as never), + "ERR_HTTP_INVALID_HEADER_VALUE", + ], + [ + "not pairs", + (): unknown => response.writeHead(200, ["X-Set", "a", "X-B"]), + "ERR_INVALID_ARG_VALUE", + ], + [ + "the reason phrase of the response", + (): unknown => { + response.statusMessage = "a\rb"; + try { + return response.writeHead(200, { "X-B": "1" }); + } finally { + response.statusMessage = "Made"; + } + }, + "ERR_INVALID_CHAR", + ], + [ + "a trailer without chunks (once Node.js has changed the status)", + (): unknown => { + response.setHeader("Trailer", "X-T").setHeader("Content-Length", 1); + try { + return response.writeHead(202, "Taken"); + } finally { + response.removeHeader("Trailer"); + response.removeHeader("Content-Length"); + } + }, + "ERR_HTTP_TRAILER_INVALID", + ], + ] as const) { + throws(refused, { code }, name); + deepStrictEqual(state(), before, name); + } + // An empty name is skipped by Node.js (of a response with headers) + response[kValidateHead](200, { "": "\n" }); + // An invalid status code first, as `writeHead()` of Node.js refuses it + for (const validate of [ + (): unknown => response[kValidateHead](1000, "a\nb"), + (): unknown => response.writeHead(1000, "a\nb"), + ]) { + throws(validate, { + code: "ERR_HTTP_INVALID_STATUS_CODE", + message: "Invalid status code: 1000", + }); + } + deepStrictEqual(state(), before); + response.writeHead(200, ["X-B", "1"]); + deepStrictEqual( + [response.statusCode, response.statusMessage, response.headersSent], + [200, "Made", true], + ); + }); + + test("a send or a flush whose headers Node.js refuses rejects, and leaves no listener", async () => { + for (const refused of [ + (r: BindenResponse): Promise => r.send(), + (r: BindenResponse): Promise => r.send("body"), + (r: BindenResponse): Promise => r.flush(), + (r: BindenResponse): Promise => r.status(205).send("body"), + ]) { + const response = detached(); + response.statusMessage = "a\nb"; + const listeners = (): number[] => + ["finish", "close", "error"].map((event) => + response.listenerCount(event), + ); + const before = listeners(); + // A promise, never a synchronous throw + const promise = refused(response); + await rejects(promise, { code: "ERR_INVALID_CHAR" }); + deepStrictEqual(listeners(), before); + ok(!response.headersSent && !response.writableEnded); + } + }); + + 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(10_000) }, (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(10_000) }, + (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", + ); + }); + + 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, + "Transfer-Encoding": "chunked", + }); + 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, + "transfer-encoding": headers["transfer-encoding"] ?? null, + }, + body.toString(), + ]; + }; + // With the length of the `200` (not the stale one set before) + const notModified = { + etag: tag, + vary: "Accept", + "cache-control": "max-age=60", + "content-length": `${Buffer.byteLength(text)}`, + "transfer-encoding": 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); + + // The default `Content-Type` of `send()`, and the length of the body in + // its encoding, as on the `200` + const latin1 = "café"; + for (const method of ["GET", "HEAD"]) { + const handled = app.handle((_request, response) => + response.send(latin1, "latin1"), + ); + const reply = await app.request({ + method, + headers: { "If-None-Match": etagOf(Buffer.from(latin1, "latin1")) }, + }); + deepStrictEqual(await handled, null); + deepStrictEqual( + [ + reply.status, + reply.headers["content-type"], + reply.headers["content-length"], + ], + [304, ct_text, "4"], + method, + ); + } + }); + + 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( + () => response.send("data", "nope" as BufferEncoding), + new TypeError("Unknown encoding: nope"), + ); + ok(!response.started); + ok(!response.hasHeader("Content-Length")); + }); + + test(".send() (stops reading a `Readable` once another writer has ended the response)", async () => { + const app = await start(); + const chunk = Buffer.alloc(64 * 1024, "a"); + const chunks = 16; + let reads = 0; + const source = new Readable({ + read(): void { + reads += 1; + this.push(reads > chunks ? null : chunk); + }, + }); + const handled = app.handle(async (_request, response) => { + // As a compression that replaces the response with an empty `406` when + // the first chunk commits the headers, and discards the next writes + response.write = (...args: unknown[]): boolean => { + const callback = args.find((arg) => typeof arg === "function") as + (() => void) | undefined; + if (!response.headersSent) { + response.writeHead(406, { "Content-Length": 0 }).end(); + } + if (callback) { + process.nextTick(callback); + } + return true; + }; + response.setHeader("Content-Length", chunks * chunk.length); + await response.send(source); + ok(response.writableFinished); + }); + const { status, headers, body } = await app.request(); + deepStrictEqual(await handled, null); + deepStrictEqual( + [status, headers["content-length"], body.length], + [406, "0", 0], + ); + // Released before its end + ok(source.destroyed); + ok(reads < chunks, `${reads}`); + }); + + 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); + // Not read, but released (a Node.js `Readable`) + deepStrictEqual([source.destroyed, source.readableFlowing], [true, null]); + }); + + 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); + }); +}); diff --git a/packages/binden/test/response-helpers.test.ts b/packages/binden/test/response-helpers.test.ts new file mode 100644 index 00000000..80a67ee1 --- /dev/null +++ b/packages/binden/test/response-helpers.test.ts @@ -0,0 +1,379 @@ +/* eslint-disable init-declarations */ +import { BindenResponse, Cookie, ct_json } from "../index.js"; +import { type ITestServer, listen } from "./http.js"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; +import { kSeal } from "../src/seal.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(), "

"); + }); + + test(".text(), .html(), .json() and .form() refused by send() (data that it cannot send, a view of a detached ArrayBuffer, a 1xx status) change nothing of the response: the Content-Type is set once send() accepts them (R26-R3-5)", async () => { + const transferred = new Uint8Array(2); + transferred.buffer.transfer(); + const params = new URLSearchParams({ a: "b" }); + const refused: [string, (response: BindenResponse) => Promise][] = [ + [ + "text({})", + async (response): Promise => response.text({} as string), + ], + [ + "html(null)", + async (response): Promise => + response.html(null as unknown as string), + ], + [ + "text(detached view)", + async (response): Promise => + response.text(transferred as unknown as string), + ], + [ + "status(101).text()", + async (response): Promise => response.status(101).text("x"), + ], + [ + "status(101).html()", + async (response): Promise => response.status(101).html("x"), + ], + [ + "status(101).json()", + async (response): Promise => response.status(101).json({}), + ], + [ + "status(101).form()", + async (response): Promise => response.status(101).form(params), + ], + ]; + for (const [label, run] of refused) { + const response = detached(); + const reason = await run(response).then( + () => null, + (error: unknown) => error, + ); + ok(reason instanceof TypeError, label); + deepStrictEqual( + [response.getHeaderNames(), response.headersSent], + [[], false], + label, + ); + } + // Ignored once the response has ended, as `send()` (without a header + // set on the ended response) + const ended = detached(); + ended.end(); + await ended.text("x"); + await ended.html("x"); + await ended.json({}); + await ended.form(params); + deepStrictEqual(ended.getHeader("Content-Type"), none); + }); + + test(".json() and .form() of a response that has ended, or that the work may no longer mutate, are ignored as send() is: they resolve without reading, checking or calling anything of their arguments (R28-R3-7)", async () => { + for (const state of ["ended", "sealed"]) { + let reads = 0; + const value = { + get a(): number { + reads += 1; + return 1; + }, + }; + const stringify = (): string => { + reads += 1; + return "{}"; + }; + const make = (): BindenResponse => { + const response = detached(); + if (state === "ended") { + response.end(); + } else { + response[kSeal](); + } + return response; + }; + const calls: [string, (response: BindenResponse) => Promise][] = [ + [ + "json(value)", + async (response): Promise => response.json(value), + ], + [ + "json(value, stringify)", + async (response): Promise => response.json(value, stringify), + ], + [ + "json(undefined)", + async (response): Promise => response.json(none), + ], + ["json(1n)", async (response): Promise => response.json(1n)], + [ + "json({}, 5)", + async (response): Promise => + response.json({}, 5 as unknown as () => string), + ], + [ + "json({}, () => 5)", + async (response): Promise => + response.json({}, () => 5 as unknown as string), + ], + [ + "form(a string)", + async (response): Promise => + response.form("a=b" as unknown as URLSearchParams), + ], + [ + "form(a getter)", + async (response): Promise => + response.form(value as unknown as URLSearchParams), + ], + ]; + for (const [label, call] of calls) { + const response = make(); + deepStrictEqual( + await call(response).then( + () => "resolved", + (error: unknown) => error, + ), + "resolved", + `${state} ${label}`, + ); + ok(!response.hasHeader("Content-Type"), `${state} ${label}`); + } + deepStrictEqual(reads, 0, state); + } + }); + + 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"), "*"); + }); + + 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); + }); +}); diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts new file mode 100644 index 00000000..0b764981 --- /dev/null +++ b/packages/binden/test/response.test.ts @@ -0,0 +1,1598 @@ +/* eslint-disable init-declarations */ +import { type AddressInfo, Socket } from "node:net"; +import { + BindenRequest, + BindenResponse, + Cookie, + ct_form, + ct_html, + ct_json, + ct_text, +} from "../index.js"; +import { + type FileHandle, + appendFile, + mkdir, + mkdtemp, + open, + rename, + rm, + stat as statOf, + utimes, + writeFile, +} from "node:fs/promises"; +import { IncomingMessage, Server, createServer, get } from "node:http"; +import { type Stats, createReadStream } from "node:fs"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; +import { toError, within } from "./helpers.js"; +import { Readable } from "node:stream"; +import { execFileSync } from "node:child_process"; +import fastJSON from "fast-json-stringify"; +import fetch from "node-fetch"; +import { join } from "node:path"; +import { once } from "node:events"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; + +/** 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 { + return new Promise((resolve) => { + get(url, (response) => { + response.once("data", () => { + response.destroy(); + resolve(); + }); + }).on("error", () => { + // The request is aborted on purpose + }); + }); +} + +/** + * 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 { + return promise.then( + () => null, + (error: unknown) => (error as { code?: unknown }).code, + ); +} + +/** 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`, + ); + try { + await within(once(socket, "close")); + } finally { + socket.destroy(); + } + 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; + let filePath: string; + let dirPath: string; + let msg: Buffer; + let server: Server; + let file_stats: Stats; + + 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(0, "127.0.0.1", () => { + url = `http://127.0.0.1:${portOf(server)}`; + resolve(); + }); + }); + file_stats = await statOf(filePath); + }); + + test("ServerResponse", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + try { + deepStrictEqual(response instanceof BindenResponse, true); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url); + await serverPromise; + }); + + test(".status()", async () => { + const status = 401; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + try { + ok(response.status(status) instanceof BindenResponse); + deepStrictEqual(response.statusCode, status); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + const response = await fetch(url); + await serverPromise; + + deepStrictEqual(response.status, status); + }); + + test(".status() (throws TypeError with unsupported code)", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + try { + 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(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + await fetch(url); + await serverPromise; + }); + + test(".set()", async () => { + const x = "X-Header"; + const y = "Y-Header"; + const z = "Z-Header"; + const headers = { [x]: 0, [y]: "0", [z]: ["0", "1"] }; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + try { + ok(response.set(headers) instanceof BindenResponse); + } catch (error) { + reject(toError(error)); + } finally { + response.end(resolve); + } + }); + }); + const response = await fetch(url); + await serverPromise; + + deepStrictEqual(response.headers.get(x), headers[x].toString()); + deepStrictEqual(response.headers.get(y), headers[y]); + 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" }); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.setHeader("Set-Cookie", [cookie1.toString()]); + response.cookies.add(cookie2); + response.send().then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + await response.text(); + + deepStrictEqual(response.headers.get("Content-Type"), null); + deepStrictEqual( + response.headers.get("Set-Cookie"), + [cookie1, cookie2].map((c) => c.toString()).join(", "), + ); + ok(response.ok); + }); + + test(".send() (`writableEnded === true`)", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.end(() => { + try { + deepStrictEqual(response.writableEnded, true); + response.send().then(resolve).catch(reject); + } catch (error) { + reject(toError(error)); + } + }); + }); + }); + const response = await fetch(url); + await serverPromise; + await response.text(); + + deepStrictEqual(response.headers.get("Content-Type"), null); + ok(response.ok); + }); + + test(".send() (`writableEnded === true` resolves, and releases a `Readable` without reading it)", 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); + deepStrictEqual([source.destroyed, source.readableFlowing], [true, null]); + // Its file is closed + if (!source.closed) { + await within(once(source, "close")); + } + }); + + test(".send() (with encoding)", async () => { + const cookie1 = new Cookie({ key: "__Secure-K1", value: "V1" }); + const cookie2 = new Cookie({ key: "__Host-K2", value: "V2" }); + const string = "Hello World!"; + const encoding = "base64"; + const encoded = Buffer.from(string).toString(encoding); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.setHeader("Set-Cookie", cookie1.toString()); + response.cookies.add(cookie2); + response.send(encoded, encoding).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.text(); + + deepStrictEqual(response.headers.get("Content-Type"), ct_text); + deepStrictEqual( + response.headers.get("Set-Cookie"), + [cookie1, cookie2].map((c) => c.toString()).join(", "), + ); + deepStrictEqual(data, string); + }); + + test(".send() (Buffer)", async () => { + const cookie1 = new Cookie({ key: "__Secure-K1", value: "V1" }); + const cookie2 = new Cookie({ key: "__Host-K2", value: "V2" }); + const string = "Hello World!"; + const buffer = Buffer.from(string); + + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.cookies.add(cookie1).add(cookie2); + response.send(buffer).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + + const data = Buffer.from(await response.arrayBuffer()); + + deepStrictEqual(response.headers.get("Content-Type"), ct_text); + deepStrictEqual( + response.headers.get("Set-Cookie"), + [cookie1, cookie2].map((c) => c.toString()).join(", "), + ); + 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(".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)); + 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 => + new BindenResponse(new IncomingMessage(new Socket())); + + // 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), "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"); + 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() (rejects when the client goes away before the flush)", + { + // 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; + 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() (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 }, + 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 => + 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"); + }); + + 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( + { ServerResponse: BindenResponse, rejectNonStandardBodyWrites: true }, + (_request, response) => { + response.send("hello").catch(() => { + // The test fails on the response instead + }); + }, + ).listen(0, "127.0.0.1"); + await once(strict, "listening"); + const { port: local } = strict.address() as AddressInfo; + + try { + const response = await fetch(`http://127.0.0.1:${local}`, { + method: "HEAD", + signal: AbortSignal.timeout(10_000), + }); + + 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() (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", + }); + // A stream or a file is framed by the length that it declares, without a + // stale `Transfer-Encoding` (also for `HEAD`) + const declared = { ...stale, "Content-Length": 3 }; + await check( + "GET", + (r) => r.set(declared).send(Readable.from([Buffer.from("abc")])), + { "content-length": "3" }, + "abc", + ); + await check( + "GET", + (r) => r.set(stale).sendFile(filePath), + { + "content-length": `${msg.byteLength}`, + "content-type": "application/octet-stream", + }, + msg.toString(), + ); + await check("HEAD", (r) => r.set(declared).send(), { + "content-length": "3", + }); + await check("HEAD", (r) => r.set(declared).send(Readable.from(["abc"])), { + "content-length": "3", + }); + await check("HEAD", (r) => r.set(stale).sendFile(filePath), { + "content-length": `${msg.byteLength}`, + "content-type": "application/octet-stream", + }); + // 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; + 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( + ".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(); + + const code = await settled; + ok(gone.has(code as string), String(code)); + ok(stream.destroyed && !stream.readableEnded); + }, + ); + + test(".send() (number)", async () => { + const number = 1; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.send(number).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.text(); + + deepStrictEqual(response.headers.get("Content-Type"), ct_text); + deepStrictEqual(data, number.toString()); + }); + + test(".send() (bigint)", async () => { + const bigint = 1n; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.send(bigint).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.text(); + + deepStrictEqual(response.headers.get("Content-Type"), ct_text); + deepStrictEqual(data, bigint.toString()); + }); + + test(".json()", async () => { + const json = { currency: "💶" }; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.json(json).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.json(); + + deepStrictEqual(response.headers.get("Content-Type"), ct_json); + deepStrictEqual(data, json); + }); + + test(".json() (an array)", async () => { + const json = [1, "2", { currency: "💶" }]; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.json(json).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.json(); + + deepStrictEqual(response.headers.get("Content-Type"), ct_json); + deepStrictEqual(data, json); + }); + + test(".json() (with a custom `stringify`)", async () => { + const json = { currency: "💶", value: 120 }; + const stringify = fastJSON({ + title: "Example Schema", + type: "object", + properties: { + currency: { + type: "string", + }, + value: { + type: "integer", + }, + }, + required: ["currency", "value"], + additionalProperties: false, + }); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.json(json, stringify).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.json(); + + deepStrictEqual(response.headers.get("Content-Type"), ct_json); + deepStrictEqual(data, json); + }); + + test(".text()", async () => { + const text = "😀"; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.text(text).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.text(); + + deepStrictEqual( + response.headers.get("Content-Type"), + `${ct_text}; charset=utf-8`, + ); + deepStrictEqual(data, text); + }); + + test(".html()", async () => { + const html = ""; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.html(html).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.text(); + + deepStrictEqual( + response.headers.get("Content-Type"), + `${ct_html}; charset=utf-8`, + ); + deepStrictEqual(data, html); + }); + + test(".form()", async () => { + const form = new URLSearchParams(); + form.append("1", "2"); + form.set("0", "1"); + form.append("1", "3"); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.form(form).then(resolve).catch(reject); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.text(); + + deepStrictEqual(response.headers.get("Content-Type"), ct_form); + deepStrictEqual(new URLSearchParams(data), form); + }); + + test(".sendFile()", async () => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response + .sendFile(filePath) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + const response = await fetch(url); + deepStrictEqual(response.status, 200); + await serverPromise; + const data = Buffer.from(await response.arrayBuffer()); + + deepStrictEqual(data, msg); + }); + + test(".sendFile() (304 response with `If-Modified-Since`)", async () => { + // Registered before the requests are sent: the second one may come + // before the send of the first has settled + const sends: Promise[] = []; + server.on("request", (_request, response) => { + sends.push( + response.sendFile(filePath).then( + () => null, + (error: unknown) => error, + ), + ); + }); + + const response = await fetch(url); + const lm = response.headers.get("Last-Modified"); + ok(lm !== null); + ok(!Number.isNaN(Date.parse(lm))); + deepStrictEqual(response.status, 200); + + const data = Buffer.from(await response.arrayBuffer()); + deepStrictEqual(data, msg); + + const headers = { "If-Modified-Since": lm }; + const response2 = await fetch(url, { headers }); + deepStrictEqual(response2.status, 304); + + const data2 = await response2.text(); + deepStrictEqual(data2, ""); + + deepStrictEqual(await within(Promise.all(sends)), [null, null]); + }); + + test(".sendFile() (304 response with `If-Modified-Since` HEAD)", async () => { + // Registered before the requests are sent: the second one may come + // before the send of the first has settled + const sends: Promise[] = []; + server.on("request", (_request, response) => { + sends.push( + response.sendFile(filePath).then( + () => null, + (error: unknown) => error, + ), + ); + }); + + const response = await fetch(url, { method: "HEAD" }); + const lm = response.headers.get("Last-Modified"); + ok(lm !== null); + ok(!Number.isNaN(Date.parse(lm))); + deepStrictEqual(response.status, 200); + + const data = await response.text(); + deepStrictEqual(data, ""); + + const headers = { "If-Modified-Since": lm }; + const response2 = await fetch(url, { headers }); + deepStrictEqual(response2.status, 304); + + const data2 = await response2.text(); + deepStrictEqual(data2, ""); + + deepStrictEqual(await within(Promise.all(sends)), [null, null]); + }); + + 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) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + + const range = `bytes=${msg.byteLength}-`; + const response = await fetch(url, { headers: { Range: range } }); + 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(); + deepStrictEqual(data, ""); + }); + + test(".sendFile() (Partial response with `Range`)", async () => { + const start = 10; + const end = 20; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response + .sendFile(filePath) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + const headers = { Range: `bytes= ${start} - ${end} ` }; + const response = await fetch(url, { headers }); + const cr = response.headers.get("Content-Range"); + deepStrictEqual(cr, `bytes ${start}-${end}/${msg.byteLength}`); + deepStrictEqual(response.status, 206); + + await serverPromise; + const data = Buffer.from(await response.arrayBuffer()); + deepStrictEqual(data, msg.subarray(start, end + 1)); + }); + + test(".sendFile() (Full response with invalid `If-Range`)", async () => { + const start = 10; + const end = 20; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response + .sendFile(filePath) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + const headers = { + Range: `bytes= ${start} - ${end} `, + "If-Range": `${new Date(file_stats.mtimeMs - 10000).toUTCString()}`, + }; + const response = await fetch(url, { headers }); + deepStrictEqual(response.headers.get("Content-Range"), null); + deepStrictEqual(response.status, 200); + + await serverPromise; + const data = Buffer.from(await response.arrayBuffer()); + deepStrictEqual(data, msg); + }); + + test(".sendFile() (Partial response with suffix length)", async () => { + const end = 20; + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response + .sendFile(filePath) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + const headers = { Range: `bytes= - ${end} ` }; + const response = await fetch(url, { headers }); + await serverPromise; + const data = Buffer.from(await response.arrayBuffer()); + const cr = response.headers.get("Content-Range"); + deepStrictEqual( + cr, + `bytes ${msg.byteLength - end}-${msg.byteLength - 1}/${msg.byteLength}`, + ); + deepStrictEqual(response.status, 206); + deepStrictEqual(data, msg.subarray(msg.byteLength - end)); + }); + + test(".sendFile() (`this.req instanceof BindenRequest`)", async () => { + const newServer = createServer({ + ServerResponse: BindenResponse, + IncomingMessage: BindenRequest, + }); + + await new Promise((resolve) => { + newServer.listen(0, "127.0.0.1", resolve); + }); + + const end = 100; + const serverPromise = new Promise((resolve, reject) => { + newServer.once("request", (_request, response) => { + response + .sendFile(filePath) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + const headers = { Range: `bytes= - ${end} ` }; + try { + const response = await fetch(`http://127.0.0.1:${portOf(newServer)}`, { + 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() (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(10_000), + }); + const body = await response.text(); + await serverPromise; + return [response.status, response.headers.get("Content-Range"), body]; + }; + const whole = msg.toString(); + const size = msg.byteLength; + // 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)]; + + // 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], + ); + + // 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 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() }, + { 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 () => { + 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); + await utimes(filePath, mtime, mtime); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.sendFile(filePath).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) => { + 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) => { + response + .sendFile(filePath) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + const response = await fetch(url); + await serverPromise; + const data = await response.text(); + deepStrictEqual(data, msg.toString()); + }); + + test(".sendFile() (with invalid protocol)", async () => { + const path = new URL("https:/www.example.com/"); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + rejects( + () => response.sendFile(path), + new TypeError(`Protocol ${path.protocol} is not supported`), + ) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + await fetch(url); + 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(10_000), + }); + 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), + new Error(`Provided path does not correspond to a regular file`), + ) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + await fetch(url); + await serverPromise; + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }); +}); diff --git a/packages/binden/test/retention.test.ts b/packages/binden/test/retention.test.ts new file mode 100644 index 00000000..93c528ff --- /dev/null +++ b/packages/binden/test/retention.test.ts @@ -0,0 +1,87 @@ +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs the retention fixture in a process of its own, whose collections the + * test controls (`--expose-gc`): whether the responses of its requests are + * collected once they have completed + */ +function retained(fixture: string, ...args: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--expose-gc", + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", fixture), + ...args, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite("A completed request", () => { + test("is not kept by a long timer that a work of it has created", () => { + deepStrictEqual(retained("retention.ts", "timers", "4000"), { + status: 0, + stdout: "collected\n", + stderr: "", + }); + }); + + test("is not kept by a listener that a work of it has left on a long-lived source", () => { + deepStrictEqual(retained("retention.ts", "shared", "4000"), { + status: 0, + stdout: "collected\n", + stderr: "", + }); + }); + + for (const [mode, how, guards] of [ + ["sent", "sent in full", "0 0"], + ["ended", "that has ended and closed before the send", "0 0"], + // Its destroy does not complete: its guard stays (its `error` and its + // `close`), the only listener of its release + ["cancelled", "cancelled with a destroy that does not complete", "400 400"], + ] as const) { + for (const binding of ["unbound", "bound"] as const) { + const bound = + binding === "bound" + ? ", whose listeners are bound as by OpenTelemetry (wrapped by its own `on()`, found by its own `removeListener()`)," + : ""; + test(`is not kept by a Readable ${how}${bound} that the application keeps: its send leaves none of its listeners`, () => { + // 400 sources + deepStrictEqual(retained("native-retention.ts", mode, binding), { + status: 0, + stdout: `collected 0 0 ${guards}\n`, + stderr: "", + }); + }); + } + } + + test("that waits for the response before it, sealed, is finalized and collected once its client goes away", () => { + // 100 clients + deepStrictEqual(retained("queued-replies.ts"), { + status: 0, + stdout: "cleanups=100 collected shutdown resolved\n", + stderr: "", + }); + }); +}); + +suite("A logger of binden", () => { + test("keeps none made before it: the earlier `context.log`s that a `child()` of the application gives are collected", () => { + // 1000 requests, whose `context.log` each is the child of the last one + deepStrictEqual(retained("flat-loggers.ts", "1000"), { + status: 0, + // The last `context.log`, and the logger of the application + stdout: "collected 2\n", + stderr: "", + }); + }); +}); diff --git a/packages/binden/test/revoked-sources.test.ts b/packages/binden/test/revoked-sources.test.ts new file mode 100644 index 00000000..18de2bd0 --- /dev/null +++ b/packages/binden/test/revoked-sources.test.ts @@ -0,0 +1,28 @@ +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; + +test("a revoked work is told, and its sources unpiped, without crashing the process, also when the cleanup of one throws, or the work is undefined (found by the fixture of the public surface)", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "revoked-sources.ts"), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + const message = "The unpipe of a source of an abandoned work has failed"; + deepStrictEqual( + { status, stdout, stderr }, + { + status: 0, + stdout: `${JSON.stringify([ + 1, + [`${message}: A hostile unpipe`, `${message}: A hostile trap`], + ])}\nan undefined work: revoked\n`, + stderr: "", + }, + ); +}); diff --git a/packages/binden/test/rollback.test.ts b/packages/binden/test/rollback.test.ts new file mode 100644 index 00000000..53aca373 --- /dev/null +++ b/packages/binden/test/rollback.test.ts @@ -0,0 +1,779 @@ +import { type AddressInfo, Socket, connect } from "node:net"; +import { + Binden, + BindenError, + BindenResponse, + type Context, + Cookie, +} from "../index.js"; +import { IncomingMessage, ServerResponse, createServer } from "node:http"; +import { capture, listen, sleep, within } from "./helpers.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { join } from "node:path"; +import { once } from "node:events"; +import { tmpdir } from "node:os"; + +/** Writes requests on one connection, and reads everything until the server closes it */ +async function converse(port: number, requests: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write(requests); + await within(once(socket, "close")); + return data.replaceAll(/\r\nDate: [^\r]*/gu, ""); +} + +/** A copy of a plain value (an object of Node.js, e.g. its headers, or an array); any other object is itself */ +function copy(value: unknown): unknown { + if (typeof value !== "object" || value === null) { + return value; + } else if (Array.isArray(value)) { + return value.map(copy); + } + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== null && prototype !== Object.prototype) { + return value; + } + return Object.fromEntries( + Reflect.ownKeys(value).map((key) => [ + String(key), + copy(Reflect.get(value, key)), + ]), + ); +} + +/** + * Every own property of a response (string and symbol keys, the contents of + * its plain objects, e.g. of its headers), the values of its accessors (also + * of the framing properties that binden stores apart), and its public state + */ +function snapshot(response: BindenResponse): Record { + const state: Record = {}; + for (const key of Reflect.ownKeys(response)) { + state[String(key)] = copy(Reflect.get(response, key)); + } + for (const name of [ + "chunkedEncoding", + "maxRequestsOnConnectionReached", + "sendDate", + "shouldKeepAlive", + "strictContentLength", + "useChunkedEncodingByDefault", + "statusCode", + "statusMessage", + "headersSent", + "writableEnded", + "started", + ]) { + state[`get ${name}`] = Reflect.get(response, name); + } + state["getHeaders()"] = copy({ ...response.getHeaders() }); + state["getRawHeaderNames()"] = ( + response as unknown as { getRawHeaderNames(): string[] } + ).getRawHeaderNames(); + state.cookies = [...response.cookies].map(String); + return state; +} + +suite("A head that Node.js refuses", () => { + test("leaves the response as it was before the attempt, field by field (also of Node.js)", async () => { + /** The attempts to commit a head that Node.js refuses */ + const attempts: Record unknown> = { + "writeHead()": (response) => response.writeHead(200), + "writeHead() with a reason and headers": (response) => + response.writeHead(201, "Made", { "X-A": "1", "Content-Length": 4 }), + "writeHead() with pairs": (response) => + response.writeHead(202, ["X-B", "2", "Connection", "close"]), + "write()": (response) => response.write("body"), + "end()": (response) => response.end("body"), + "end() of bytes in an encoding": (response) => + response.end(Buffer.from("body"), "base64"), + "flushHeaders()": (response) => { + response.flushHeaders(); + }, + // Node.js has decided that it has no body + "writeHead(204)": (response) => response.writeHead(204), + // ... and not to keep the connection alive, as it is chunked + "writeHead(304) of a chunked body": (response) => + response.writeHead(304, { "Transfer-Encoding": "chunked" }), + }; + /** The headers set before (with a `Trailer` that Node.js refuses for a body that is not chunked) */ + const setups: Record void> = { + "a length and a connection kept alive": (response) => { + response.setHeader("Connection", "keep-alive"); + response.setHeader("Keep-Alive", "timeout=5"); + response.setHeader("Content-Length", 4); + }, + // Neither a length nor chunks: Node.js closes the connection + "removed framing and a cookie": (response) => { + response.setHeader("Content-Length", 4); + response.removeHeader("Content-Length"); + response.removeHeader("Transfer-Encoding"); + response.cookies.add(new Cookie({ key: "a", value: "1" })); + }, + // Removing it would not send a `Date` + "a date": (response) => { + response.setHeader("Content-Length", 4); + response.setHeader("Date", "Thu, 01 Jan 2026 00:00:00 GMT"); + }, + // Node.js counts the bytes of a chunk before it commits the head + "a strict length": (response) => { + response.strictContentLength = true; + response.setHeader("Content-Length", 4); + }, + }; + const failures: string[] = []; + const server = createServer( + { ServerResponse: BindenResponse }, + (request: IncomingMessage, response: BindenResponse) => { + if (request.url === "/next") { + response.end("next"); + return; + } + const { pathname, searchParams } = new URL( + request.url ?? "/", + "http://a", + ); + setups[decodeURIComponent(pathname).slice(1)](response); + response.setHeader("Trailer", "X-T"); + // Without the attempts: the reply that the others must send + const control = searchParams.has("control"); + for (const [name, attempt] of Object.entries(control ? {} : attempts)) { + const before = snapshot(response); + throws( + () => attempt(response), + { code: "ERR_HTTP_TRAILER_INVALID" }, + name, + ); + const after = snapshot(response); + for (const key of new Set([ + ...Object.keys(before), + ...Object.keys(after), + ])) { + try { + deepStrictEqual(after[key], before[key]); + } catch { + failures.push(`${request.url}: ${name}: ${key}`); + } + } + } + // The next reply is framed as if nothing had been attempted + response.removeHeader("Trailer"); + response.setHeader("Content-Length", 4).end("body"); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const { port } = server.address() as AddressInfo; + for (const version of ["1.1", "1.0"]) { + for (const setup of Object.keys(setups)) { + const keep = version === "1.0" ? "Connection: keep-alive\r\n" : ""; + const exchange = (query: string): Promise => + converse( + port, + `GET /${encodeURIComponent(setup)}${query} HTTP/${version}\r\nHost: a\r\n${keep}\r\n` + + `GET /next HTTP/${version}\r\nHost: a\r\nConnection: close\r\n\r\n`, + ); + const name = `HTTP/${version}: ${setup}`; + const data = await exchange(""); + // Byte for byte the reply of a response without the attempts + deepStrictEqual(data, await exchange("?control"), name); + ok( + data.includes("\r\n\r\nbodyHTTP/1.1 200 OK\r\n"), + `${name}: ${data}`, + ); + ok(data.endsWith("\r\n\r\nnext"), `${name}: ${data}`); + } + } + } finally { + server.closeAllConnections(); + server.close(); + } + deepStrictEqual(failures, []); + }); + + test("the error reply is framed as if it had not been attempted, and the next request is answered", async () => { + const big = "t".repeat(3000); + /** Replies whose head Node.js refuses (a `Trailer` of a body that is not chunked) */ + const replies: Record unknown> = { + // Set before: the connection kept alive, and a length + length: ({ response }) => { + response.setHeader("Connection", "keep-alive"); + response.setHeader("Keep-Alive", "timeout=5"); + response.setHeader("Content-Length", big.length); + response.setHeader("Trailer", "X-T"); + response.end(big); + }, + // Of HTTP/1.0: neither a length nor chunks + stream: ({ response }) => { + response.setHeader("Trailer", "X-T"); + return response.type("text/plain").send(Readable.from([big])); + }, + write: ({ response }) => { + response.setHeader("Trailer", "X-T"); + response.write(big); + response.end(); + }, + }; + const app = new Binden().use(async (context: Context) => { + const reply = replies[context.url.pathname.slice(1)]; + if (typeof reply === "undefined") { + await context.text("next"); + return; + } + await reply(context); + }); + const listening = await listen(app); + try { + // HTTP/1.1 chunks a body without a length (with its trailers) + const refused: [string, string[]][] = [ + ["1.1", ["length"]], + ["1.0", Object.keys(replies)], + ]; + for (const [version, paths] of refused) { + for (const path of paths) { + const name = `HTTP/${version} ${path}`; + const keep = version === "1.0" ? "Connection: keep-alive\r\n" : ""; + const data = await converse( + listening.port, + `GET /${path} HTTP/${version}\r\nHost: a\r\n${keep}\r\n` + + `GET /next HTTP/${version}\r\nHost: a\r\n${keep}\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const [failed = "", ...rest] = data.split(/(?=HTTP\/1\.1 )/u); + const head = failed.split("\r\n"); + deepStrictEqual(head[0], "HTTP/1.1 500 Internal Server Error", name); + ok(head.includes("Content-Length: 0"), `${name}: ${failed}`); + ok(head.includes("Connection: keep-alive"), `${name}: ${failed}`); + // Never a `Trailer` nor another framing + ok(!/^(?:Trailer|Transfer-Encoding)/imu.test(failed), failed); + deepStrictEqual(rest.length, 2, `${name}: ${data}`); + for (const next of rest) { + ok(next.startsWith("HTTP/1.1 200 OK\r\n"), `${name}: ${next}`); + ok(next.endsWith("\r\n\r\nnext"), `${name}: ${next}`); + } + } + } + } finally { + await listening.close(); + } + }); + + test("the bytes of a refused chunk are not counted for `strictContentLength`", async () => { + /** A refused head (a `Trailer` of a body that is not chunked) of each path */ + const attempts: Record unknown> = { + end: (response) => response.end("body"), + write: (response) => response.write("body"), + }; + const refused: unknown[] = []; + const server = createServer( + { ServerResponse: BindenResponse }, + (request: IncomingMessage, response: BindenResponse) => { + const attempt = attempts[(request.url ?? "/").slice(1)]; + if (typeof attempt === "undefined") { + response.end("next"); + return; + } + response.strictContentLength = true; + response.setHeader("Content-Length", 4); + response.setHeader("Trailer", "X-T"); + try { + attempt(response); + } catch (error: unknown) { + refused.push((error as { code?: unknown }).code); + } + response.removeHeader("Trailer"); + response.end("body"); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const { port } = server.address() as AddressInfo; + for (const name of Object.keys(attempts)) { + const data = await converse( + port, + `GET /${name} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const [first = "", next = "", ...rest] = data.split(/(?=HTTP\/1\.1 )/u); + deepStrictEqual( + [first, rest], + [ + "HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\nbody", + [], + ], + name, + ); + ok(next.endsWith("\r\n\r\nnext"), `${name}: ${next}`); + } + deepStrictEqual(refused, [ + "ERR_HTTP_TRAILER_INVALID", + "ERR_HTTP_TRAILER_INVALID", + ]); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + test("the error reply of a refused strict chunk has its length, and the next request is answered", async () => { + const replies: Record unknown> = { + end: ({ response }) => { + response.end("abc"); + }, + write: ({ response }) => { + response.write("abc"); + }, + text: (context) => context.text("abc"), + }; + const app = new Binden().use(async (context: Context) => { + const reply = replies[context.url.pathname.slice(1)]; + if (typeof reply === "undefined") { + await context.text("next"); + return; + } + const { response } = context; + response.strictContentLength = true; + response.setHeader("Trailer", "X-T"); + response.setHeader("Content-Length", 3); + await reply(context); + }); + const listening = await listen(app); + try { + for (const path of Object.keys(replies)) { + const data = await converse( + listening.port, + `GET /${path} HTTP/1.1\r\nHost: a\r\n\r\n` + + "GET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const [failed = "", next = "", ...rest] = + data.split(/(?=HTTP\/1\.1 )/u); + deepStrictEqual( + [failed, rest], + [ + "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\n\r\n", + [], + ], + path, + ); + ok(next.endsWith("\r\n\r\nnext"), `${path}: ${next}`); + } + } finally { + await listening.close(); + } + }); + + test("an error reply whose end() throws once its head is committed closes the connection", async () => { + // The counter of `strictContentLength` of Node.js, set as a refused head + // would have left it if it were not restored: the `end()` of every reply + // throws once it has committed its head + const counter = Object.getOwnPropertySymbols( + new BindenResponse(new IncomingMessage(new Socket())), + ).find((key) => key.description === "kBytesWritten") as symbol; + const miscount = (response: BindenResponse): void => { + response.strictContentLength = true; + Reflect.set(response, counter, 3); + }; + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + error_handler: ({ request, response }: Context): void => { + if (request.url === "/handler") { + // Its own failure: the framework replies an opaque `500` + miscount(response); + throw new Error("Failed"); + } + }, + }).use(({ response }: Context) => { + miscount(response); + throw new Error("Failed"); + }); + const listening = await listen(app); + try { + for (const path of ["/stack", "/handler"]) { + // The head of the reply is never sent: closed, rather than left open + const data = await converse( + listening.port, + `GET ${path} HTTP/1.1\r\nHost: a\r\n\r\n`, + ); + deepStrictEqual(data, "", path); + } + const unsent = captured.records.filter( + ({ msg }) => msg === "The error reply has not been sent", + ); + deepStrictEqual( + unsent.map( + ({ failure }) => (failure as { code?: unknown } | undefined)?.code, + ), + [ + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ], + ); + } finally { + await listening.close(); + } + }); + + test("an error reply that has ended is not cut when its send fails later", async () => { + const late: unknown[] = []; + const closed: unknown[] = []; + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + error_handler: ({ response }: Context): void => { + // Does not reply (the framework does), then writes once the reply has + // ended, while it waits for the response before it: Node.js emits + // `ERR_STREAM_WRITE_AFTER_END`, and the send of the reply rejects + setTimeout(() => { + late.push(response.writableEnded, response.writableFinished); + response.write("late"); + }, 50); + }, + }).use(async (context: Context) => { + if (context.url.pathname === "/first") { + await sleep(150); + await context.text("first"); + return; + } + throw new BindenError(500, { expose: true, message: "failed" }); + }); + const listening = await listen(app); + listening.server.on( + "request", + (request: IncomingMessage, response: BindenResponse) => { + response.once("close", () => { + closed.push([request.url, response.writableFinished]); + }); + }, + ); + try { + const data = await converse( + listening.port, + "GET /first HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /second HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const [first = "", second = "", ...rest] = data.split(/(?=HTTP\/1\.1 )/u); + ok(first.endsWith("\r\n\r\nfirst"), first); + ok(second.startsWith("HTTP/1.1 500 Internal Server Error\r\n"), second); + ok(second.endsWith("\r\n\r\nfailed"), second); + deepStrictEqual(rest, []); + deepStrictEqual(late, [true, false]); + deepStrictEqual(closed, [ + ["/first", true], + ["/second", true], + ]); + const { failure } = await captured.waitFor( + "The error reply has not been sent", + ); + deepStrictEqual( + (failure as { code?: unknown }).code, + "ERR_STREAM_WRITE_AFTER_END", + ); + } finally { + await listening.close(); + } + }); + + test("a failure once the head is committed is not rolled back", async () => { + const outcomes: unknown[] = []; + const server = createServer( + { ServerResponse: BindenResponse }, + (_request: IncomingMessage, response: BindenResponse) => { + // Strict: Node.js throws once it has committed the head + response.strictContentLength = true; + response.setHeader("Content-Length", 10); + try { + response.end("abc"); + } catch (error: unknown) { + outcomes.push((error as { code?: unknown }).code); + } + outcomes.push( + response.headersSent, + response.getHeader("Content-Length"), + ); + response.destroy(); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + await converse( + (server.address() as AddressInfo).port, + "GET / HTTP/1.1\r\nHost: a\r\n\r\n", + ); + deepStrictEqual(outcomes, ["ERR_HTTP_CONTENT_LENGTH_MISMATCH", true, 10]); + } finally { + server.closeAllConnections(); + server.close(); + } + }); +}); + +suite("An end() that throws", () => { + /** Reads everything from one connection until the server closes it, within 10 s */ + async function read(port: number, data: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + const closed = once(socket, "close"); + socket.write(data); + try { + await within(closed); + } finally { + socket.destroy(); + } + return Buffer.concat(chunks).toString("latin1"); + } + + test("leaves the socket corked as it was: the reply that follows is not held", async () => { + const root = await mkdtemp(join(tmpdir(), "binden-uncork-")); + // Larger than the buffers of the socket: its pipe waits for `drain` + const page = "p".repeat(266_000); + await writeFile(join(root, "page.txt"), page); + /** Ends that Node.js refuses once it has corked the socket, or that binden refuses before */ + const ends: Partial unknown>> = + { + // Valid for HTTP/1.1: HTTP/1.0 has no chunks for the trailers + trailers: (response) => { + response.setHeader("Trailer", "Server-Timing"); + response.addTrailers({ "Server-Timing": "db;dur=1" }); + return response.end("x".repeat(100)); + }, + object: (response) => response.end({}), + number: (response) => response.end(42), + }; + /** Replies after it, which wait for the socket to flush */ + const replies: Partial< + Record Promise> + > = { + flush: async ({ response }) => { + response.removeHeader("Trailer"); + response.status(500).setHeader("Content-Type", "text/plain"); + response.write("part;"); + await response.flush(); + response.end("rest"); + }, + file: async ({ response }) => { + response.removeHeader("Trailer"); + await response.status(500).sendFile("page.txt", { root }); + }, + }; + const corks: unknown[] = []; + const app = new Binden({ + // Far beyond the time of the client + error_handler_timeout: 30_000, + error_handler: async (context: Context): Promise => { + const [, , reply = ""] = context.url.pathname.split("/"); + await replies[reply]?.(context); + }, + }).use(({ url, response }: Context) => { + const [, end = ""] = url.pathname.split("/"); + // The response of the connection (no other is before it) + const socket = response.socket as Socket; + const before = socket.writableCorked; + let corked = 0; + socket.cork = (): void => { + corked += 1; + Socket.prototype.cork.call(socket); + }; + try { + ends[end]?.(response); + } catch (error: unknown) { + const { code } = error as { code?: unknown }; + corks.push([end, code, before, corked], socket.writableCorked); + throw error; + } finally { + Reflect.deleteProperty(socket, "cork"); + } + }); + const listening = await listen(app); + try { + for (const end of Object.keys(ends)) { + for (const [reply, body] of [ + ["flush", "part;rest"], + ["file", page], + ]) { + const name = `${end} then ${reply}`; + corks.length = 0; + const data = await read( + listening.port, + `GET /${end}/${reply} HTTP/1.0\r\nHost: a\r\n\r\n`, + ); + ok( + data.startsWith("HTTP/1.1 500 Internal Server Error\r\n"), + `${name}: ${data.slice(0, 200)}`, + ); + ok(data.endsWith(`\r\n\r\n${body}`), `${name}: ${data.length}`); + // Corked by Node.js, then uncorked; a chunk that is not one is + // refused before Node.js corks the socket + const [code, corked] = + end === "trailers" + ? ["ERR_HTTP_TRAILER_INVALID", 1] + : ["ERR_INVALID_ARG_TYPE", 0]; + deepStrictEqual(corks, [[end, code, 0, corked], 0], name); + } + } + } finally { + await listening.close(); + await rm(root, { recursive: true }); + } + }); + + test("caught by the application leaves the socket to its next reply", async () => { + const root = await mkdtemp(join(tmpdir(), "binden-uncork-")); + const page = "p".repeat(266_000); + await writeFile(join(root, "page.txt"), page); + const refused: unknown[] = []; + const app = new Binden({ error_handler_timeout: 30_000 }).use( + async ({ response }: Context) => { + for (const chunk of [{}, 42]) { + try { + response.end(chunk); + } catch (error: unknown) { + refused.push((error as { code?: unknown }).code); + } + } + await response.sendFile("page.txt", { root }); + }, + ); + const listening = await listen(app); + try { + const data = await read( + listening.port, + "GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + ok(data.startsWith("HTTP/1.1 200 OK\r\n"), data.slice(0, 200)); + ok(data.endsWith(`\r\n\r\n${page}`), String(data.length)); + deepStrictEqual(refused, [ + "ERR_INVALID_ARG_TYPE", + "ERR_INVALID_ARG_TYPE", + ]); + } finally { + await listening.close(); + await rm(root, { recursive: true }); + } + }); + + test("of a chunk that is not one, once the response has ended, is left to Node.js", async () => { + const results: unknown[] = []; + for (const Response of [ServerResponse, BindenResponse]) { + const outcomes: unknown[] = []; + const server = createServer( + { ServerResponse: Response as typeof BindenResponse }, + (_request: IncomingMessage, response: ServerResponse) => { + response.on("error", (error: { code?: unknown }) => { + outcomes.push(["error", error.code]); + }); + response.end("ended"); + try { + response.end({}, () => { + outcomes.push("called"); + }); + outcomes.push("returned"); + } catch (error: unknown) { + outcomes.push(["thrown", (error as { code?: unknown }).code]); + } + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const data = await converse( + (server.address() as AddressInfo).port, + "GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + ok(data.endsWith("\r\n\r\nended"), data); + results.push(outcomes); + } finally { + server.closeAllConnections(); + server.close(); + } + } + const [native, binden] = results; + deepStrictEqual(binden, native); + deepStrictEqual(native, [ + "returned", + "called", + ["error", "ERR_STREAM_WRITE_AFTER_END"], + ]); + }); + + test("keeps the corks of the application", async () => { + const corks: unknown[] = []; + const app = new Binden().use(({ response }: Context) => { + const socket = response.socket as Socket; + response.cork(); + const before = socket.writableCorked; + try { + response.setHeader("Trailer", "Server-Timing"); + response.addTrailers({ "Server-Timing": "db;dur=1" }); + response.end("body"); + } finally { + corks.push(before, socket.writableCorked); + } + }); + const listening = await listen(app); + try { + // The error reply uncorks the socket as it ends (as Node.js does) + const data = await read( + listening.port, + "GET / HTTP/1.0\r\nHost: a\r\n\r\n", + ); + ok(data.startsWith("HTTP/1.1 500 Internal Server Error\r\n"), data); + deepStrictEqual(corks, [1, 1]); + } finally { + await listening.close(); + } + }); + + test("of a response that waits for the one before it is the error of Node.js", async () => { + const outcomes: unknown[] = []; + const server = createServer( + { ServerResponse: BindenResponse }, + (request: IncomingMessage, response: BindenResponse) => { + if (request.url === "/first") { + setTimeout(() => { + response.end("first"); + }, 50); + return; + } + // No socket yet: nothing has been corked + outcomes.push(response.socket); + response.setHeader("Content-Length", 4); + response.setHeader("Trailer", "X-T"); + try { + response.end("body"); + } catch (error: unknown) { + outcomes.push((error as { code?: unknown }).code); + } + response.removeHeader("Trailer"); + response.setHeader("Content-Length", 6).end("second"); + }, + ); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const data = await converse( + (server.address() as AddressInfo).port, + "GET /first HTTP/1.1\r\nHost: a\r\n\r\n" + + "GET /second HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + ok(data.includes("\r\n\r\nfirstHTTP/1.1 200 OK\r\n"), data); + ok(data.endsWith("\r\n\r\nsecond"), data); + deepStrictEqual(outcomes, [null, "ERR_HTTP_TRAILER_INVALID"]); + } finally { + server.closeAllConnections(); + server.close(); + } + }); +}); diff --git a/test/router.test.ts b/packages/binden/test/router.test.ts similarity index 70% rename from test/router.test.ts rename to packages/binden/test/router.test.ts index 4de76381..309c3188 100644 --- a/test/router.test.ts +++ b/packages/binden/test/router.test.ts @@ -1,6 +1,6 @@ +import { Middleware, Router } from "../index.js"; import { deepStrictEqual, throws } from "node:assert"; - -import { Router, Middleware } from "../index.js"; +import { suite, test } from "node:test"; class CustomMiddleware extends Middleware { public i = 0; @@ -59,11 +59,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_"; + 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(unsupportedMethod), - new TypeError(`Method ${unsupportedMethod} is not supported`), + () => new Router({ guarded: 1 as unknown as boolean }), + new TypeError("`guarded` is not a boolean"), ); }); @@ -107,14 +138,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..e0c75a26 --- /dev/null +++ b/packages/binden/test/routing.test.ts @@ -0,0 +1,729 @@ +import { + Binden, + BindenError, + Context, + type IBindenOptions, + Middleware, + Router, + Stack, +} from "../index.js"; +import { type IApp, type IListening, listen, send } from "./helpers.js"; +import { compile, matchPrefix, matchRoute } from "../src/route.js"; +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { layersOf } from "../src/stack.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("`use()` refuses a call without items, or whose first argument is no path", () => { + // A stack (for `mount()`) or `undefined` is not a path, and a `use()` + // without items adds nothing: neither is silently ignored + const stack = new Stack(); + const use = stack.use.bind(stack) as (...args: unknown[]) => Stack; + const invalid: unknown[][] = [ + [new Stack()], + [new Binden()], + [new Stack(), new Noop()], + // eslint-disable-next-line no-undefined -- the refused argument + [undefined], + // eslint-disable-next-line no-undefined -- the refused argument + [undefined, new Noop()], + [null, new Noop()], + [{}, new Noop()], + [], + ["/path"], + [/^\/path$/u], + ]; + for (const args of invalid) { + throws( + () => use(...args), + new TypeError("Unsupported Middleware/Router"), + ); + } + deepStrictEqual(stack.stack, []); + // A path (also an empty one, for every path) or an item comes first + const a = new Noop(); + deepStrictEqual(use("", a).use(a).stack, [[[a, a], null]]); + }); + + 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("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 }, + }) + .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("a path is split once for the layers of a stack, whatever their number", async (t) => { + // A long path that no layer matches, through many `route()` and + // `mount()` layers (and a mounted stack) + const path = `/${"a/".repeat(1000)}b`; + const splits = async (layers: number): Promise => { + const app = new Binden().mount("/", new Stack().use("/x", echo())); + for (let index = 0; index < layers; index += 1) { + app.route(`/r${index}/:id`, echo()).mount(`/m${index}`, echo()); + } + let calls = 0; + await withApp(app, async ({ port }) => { + const split = t.mock.method(String.prototype, "split"); + try { + deepStrictEqual((await get(port, path))[0], 404); + } finally { + // The splits of the long path (or of its rest at a mount) + calls = split.mock.calls.filter( + (call) => String(call.this).length >= path.length - 1, + ).length; + split.mock.restore(); + } + }); + return calls; + }; + const few = await splits(2); + const many = await splits(200); + // The request-target, the root and the mounted stack + ok(few <= 3, `${few} splits`); + deepStrictEqual(many, few); + }); + + 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, + // 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 }]); + }); + }); + + 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); + // Its routers are listed once + deepStrictEqual((await get(port, "*", "OPTIONS"))[0], 204); + }); + }); + }); + + 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"], + ); + }); + }); + test("`OPTIONS *` gets `204` with the methods of every router, without running the stack", async () => { + let runs = 0; + const disabled = new Noop(); + disabled.disabled = true; + const users = new Stack() + .route("/:id", new Router({ guarded: true }).put(echo())) + .use(new Router().on("propfind", echo()).delete(disabled)); + const app = new Binden() + .use(() => { + runs += 1; + }) + .use("/a", new Router().get(echo()).post(echo())) + // Twice, and in itself through another stack: once + .mount("/users", users) + .mount("/again", users, new Stack().mount("/users", users)); + const noHead = new Binden({ auto_head: false }).use( + new Router().get(echo()), + ); + const none = new Binden().use(echo()); + await withApp(app, async ({ port }) => { + const all = await send(port, { path: "*", method: "OPTIONS" }); + deepStrictEqual( + [all.status, all.headers.allow, all.body], + [204, "GET, POST, PUT, PROPFIND, HEAD, OPTIONS", ""], + ); + // Another target is routed + deepStrictEqual( + (await send(port, { path: "/a", method: "OPTIONS" })).status, + 404, + ); + deepStrictEqual(runs, 1); + }); + await withApp(noHead, async ({ port }) => { + const all = await send(port, { path: "*", method: "OPTIONS" }); + deepStrictEqual([all.status, all.headers.allow], [204, "GET, OPTIONS"]); + }); + await withApp(none, async ({ port }) => { + const all = await send(port, { path: "*", method: "OPTIONS" }); + deepStrictEqual([all.status, all.headers.allow], [204, "OPTIONS"]); + // `*` is the target of `OPTIONS` only + deepStrictEqual((await send(port, { path: "*" })).status, 400); + }); + }); + }); + + 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()); + // A promise (refused by the types), whose rejection is observed + let observed = 0; + const thenable = { + then: (_resolve: unknown, reject: (error: unknown) => void): void => { + observed += 1; + reject(new Error("A rejected state")); + }, + }; + const promise = new Binden({ + state: (): Record => thenable, + }).use(echo()); + for (const app of [failing, invalid, promise]) { + await withApp(app, async ({ port }) => { + deepStrictEqual((await get(port, "/"))[0], 500); + }); + } + deepStrictEqual(observed, 1); + }); + }); +}); diff --git a/packages/binden/test/send-file-kinds.test.ts b/packages/binden/test/send-file-kinds.test.ts new file mode 100644 index 00000000..8f9febf2 --- /dev/null +++ b/packages/binden/test/send-file-kinds.test.ts @@ -0,0 +1,156 @@ +/* eslint-disable init-declarations */ +import { BindenError, type ISendFileOptions } from "../index.js"; +import { type IReply, type ITestServer, listen } from "./http.js"; +import { + after, + afterEach, + before, + beforeEach, + mock, + suite, + test, +} from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import fsp, { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { once } from "node:events"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; + +interface IServed extends IReply { + /** `null` once `sendFile()` has resolved, otherwise its error */ + readonly error: unknown; +} + +const posixOnly = { skip: process.platform === "win32" }; + +suite("sendFile (kinds of files)", () => { + let root: string; + let server: ITestServer; + + before(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "binden-kinds-"))); + await writeFile(join(root, "file.txt"), "file"); + }); + + after(async () => { + await rm(root, { 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: string, + options?: ISendFileOptions, + ): Promise => { + let error: unknown = null; + const handled = server.handle(async (_request, response) => { + try { + await response.sendFile(path, options); + } catch (reason: unknown) { + error = reason; + const status = reason instanceof BindenError ? reason.status : 500; + await response.status(status).send(); + } + }); + const reply = await server.request(); + deepStrictEqual(await handled, null); + return { ...reply, error }; + }; + + /** Counts the files that `sendFile()` opens */ + const counting = (): { count: number } => { + const opened = { count: 0 }; + const { open: original } = fsp; + mock.method(fsp, "open", async (...args: Parameters) => { + opened.count += 1; + return original(...args); + }); + syncBuiltinESMExports(); + return opened; + }; + + test("a socket under `root` is not found", posixOnly, async () => { + const path = join(root, "socket"); + const listening = createServer().listen(path); + await once(listening, "listening"); + try { + const opened = counting(); + const { status, error } = await serve("socket", { root }); + deepStrictEqual([status, opened.count], [404, 0]); + ok(error instanceof BindenError); + } finally { + listening.close(); + await once(listening, "close"); + } + }); + + test("a FIFO under `root` is not found, nor opened", posixOnly, async () => { + execFileSync("mkfifo", [join(root, "fifo")]); + const opened = counting(); + deepStrictEqual((await serve("fifo", { root })).status, 404); + deepStrictEqual(opened.count, 0); + }); + + test("a file that cannot be opened as a regular file under `root` is not found", async () => { + const failing = (code: string): void => { + mock.restoreAll(); + const error = Object.assign(new Error(code), { code }); + mock.method(fsp, "open", () => Promise.reject(error)); + syncBuiltinESMExports(); + }; + // E.g. replaced by a socket (`ENXIO`, or `EOPNOTSUPP` on macOS) or a + // device without a driver (`ENODEV`) once checked + for (const code of ["ENXIO", "EOPNOTSUPP", "ENODEV"]) { + failing(code); + const under = await serve("file.txt", { root }); + deepStrictEqual(under.status, 404, code); + ok(under.error instanceof BindenError); + deepStrictEqual((under.error.cause as { code?: unknown }).code, code); + // A trusted path rejects with the error of the filesystem + const trusted = await serve(join(root, "file.txt")); + deepStrictEqual( + [trusted.status, (trusted.error as { code?: unknown }).code], + [500, code], + ); + } + }); + + test( + "a file replaced by a directory once checked is not found", + posixOnly, + async () => { + await mkdir(join(root, "dir")); + const { lstat: original } = fsp; + // A regular file when checked, a directory when opened + mock.method(fsp, "lstat", async (path: string) => + Object.assign(await original(path), { isFile: () => true }), + ); + syncBuiltinESMExports(); + const { status, error } = await serve("dir", { root }); + deepStrictEqual(status, 404); + ok(error instanceof BindenError); + }, + ); + + test("a file removed once its real path is resolved is not found", async () => { + const error = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + mock.method(fsp, "lstat", () => Promise.reject(error)); + syncBuiltinESMExports(); + const { status, error: reason } = await serve("file.txt", { root }); + deepStrictEqual(status, 404); + ok(reason instanceof BindenError); + deepStrictEqual(reason.cause, error); + }); +}); diff --git a/packages/binden/test/send-file-refusals.test.ts b/packages/binden/test/send-file-refusals.test.ts new file mode 100644 index 00000000..f4e8a8a1 --- /dev/null +++ b/packages/binden/test/send-file-refusals.test.ts @@ -0,0 +1,225 @@ +import { Binden, type Context } from "../index.js"; +import { converse, get } from "./connection.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { join } from "node:path"; +import { listen } from "./helpers.js"; + +/** A file that `sendFile()` sends */ +const file = join(import.meta.dirname, "..", "package.json"); + +/** The message of the refusal of an informational status */ +const informational = (status: number): string => + `Status code ${String(status)} is informational (use writeEarlyHints())`; + +/** + * Replies a request by `reply`, then the pipelined request that follows it + * with `next`: what the client receives (without the `Date` of each + * response) until the connection closes + */ +async function received( + reply: (context: Context) => Promise, +): Promise { + const app = new Binden().use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.send("next"); + return; + } + await reply(context); + }); + const listening = await listen(app); + try { + const { received: text } = await converse( + listening.port, + `${get("/")}GET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, + ); + return text.replaceAll(/Date: [^\r]*\r\n/gu, ""); + } finally { + await listening.close(); + } +} + +/** How a work replies once its `sendFile()` has rejected (see `received()`) */ +const replies: Record< + string, + (context: Context, error: Error) => Promise +> = { + "end()": async ({ response }) => { + response.statusCode = 500; + response.end("err"); + return Promise.resolve(); + }, + "send()": async (context) => { + context.response.statusCode = 500; + await context.send("err"); + }, + // The error handler of binden replies + "a rejection": async (_, error) => Promise.reject(error), +}; + +/** The code (or else the message) of what a promise rejects with (`null` once it resolves) */ +async function rejection(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => { + const { code, message } = error as { code?: unknown; message: string }; + return code ?? message; + }, + ); +} + +suite( + "sendFile() refuses an informational status before it sets anything, as send() (H29-3)", + () => { + for (const [name, reply] of Object.entries(replies)) { + test(`the reply that follows (by ${name}) is framed as if the file had never been sent`, async () => { + for (const status of [100, 103, 199]) { + /** The rejections of the sends of the file, by the first request */ + let rejected: unknown = null; + let read = 0; + let headers: unknown = null; + const options = { + get etag(): boolean { + read += 1; + return true; + }, + }; + const ours = await received(async (context) => { + const { response } = context; + response.setHeader("X-Before", "1"); + response.statusCode = status; + const error = await response.sendFile(file, options).then( + () => null, + (failure: unknown) => failure, + ); + rejected = (error as Error | null)?.message; + headers = { ...response.getHeaders() }; + await reply(context, error as Error); + }); + deepStrictEqual( + [rejected, read, headers], + [informational(status), 0, { "x-before": "1" }], + `${name} ${String(status)}`, + ); + // The same reply without the file + const expected = await received(async (context) => { + context.response.setHeader("X-Before", "1"); + await reply(context, new TypeError(informational(status))); + }); + ok(expected.endsWith("\r\n\r\nnext"), `${name} ${String(status)}`); + deepStrictEqual(ours, expected, `${name} ${String(status)}`); + } + }); + } + + test("an object status is converted once, then refused before the file is opened", async () => { + let conversions = 0; + let outcome: unknown = null; + await received(async ({ response }) => { + response.statusCode = { + [Symbol.toPrimitive](): number { + conversions += 1; + return 103; + }, + } as never; + outcome = await rejection(response.sendFile(file)); + deepStrictEqual(response.statusCode, 103); + response.statusCode = 500; + response.end(); + }); + deepStrictEqual([outcome, conversions], [informational(103), 1]); + }); + }, +); + +suite( + "A sendFile() that fails once it has set the headers of the file restores the response as it was before it (H29-3)", + () => { + test("a status that becomes informational while the file is opened: the reply that follows has none of the headers of the file, nor a length set before it", async () => { + let outcome: unknown = null; + let headers: unknown = null; + let status: unknown = null; + const ours = await received(async ({ response }) => { + response.setHeader("X-Before", "1"); + response.setHeader("Content-Length", 10); + const sent = response.sendFile(file); + response.statusCode = 103; + outcome = await rejection(sent); + headers = { ...response.getHeaders() }; + status = response.statusCode; + response.statusCode = 500; + response.end("err"); + }); + deepStrictEqual( + [outcome, headers, status], + [informational(103), { "x-before": "1" }, 200], + ); + // Without the length set before it, which Node.js remembers (the + // reply is chunked) + const expected = await received(async ({ response }) => { + response.setHeader("X-Before", "1"); + response.setHeader("Content-Length", 10); + response.removeHeader("Content-Length"); + response.statusCode = 500; + response.end("err"); + return Promise.resolve(); + }); + ok(expected.endsWith("\r\n\r\nnext")); + deepStrictEqual(ours, expected); + }); + + test("a head that Node.js refuses (its reason phrase): the reply of the error has none of the headers of the file", async () => { + let outcome: unknown = null; + let headers: unknown = null; + await received(async ({ response }) => { + response.setHeader("X-Before", "1"); + response.statusMessage = "bad\r\nx: y"; + outcome = await rejection(response.sendFile(file)); + headers = { ...response.getHeaders() }; + response.statusMessage = ""; + response.statusCode = 500; + response.end(); + }); + deepStrictEqual( + [outcome, headers], + ["ERR_INVALID_CHAR", { "x-before": "1" }], + ); + }); + + test("a failure before the headers of the file are set (a path that is not a regular file) leaves the response as it is, also what has been set meanwhile", async () => { + let outcome: unknown = null; + let headers: unknown = null; + await received(async ({ response }) => { + response.setHeader("X-Before", "1"); + // A directory: refused once it is opened (or as it is opened, by + // the system), before anything of it is set + const sent = response.sendFile(import.meta.dirname); + response.setHeader("X-During", "1"); + outcome = await rejection(sent); + headers = { ...response.getHeaders() }; + response.statusCode = 500; + response.end(); + }); + ok(outcome !== null); + deepStrictEqual(headers, { "x-before": "1", "x-during": "1" }); + }); + + test("once the head is committed, a sendFile() that fails changes nothing more: it rejects with the error of Node.js", async () => { + let outcome: unknown = null; + const text = await received(async ({ response }) => { + response.setHeader("Content-Length", 0); + response.flushHeaders(); + outcome = await response.sendFile(file).then( + () => null, + (error: unknown) => (error as Error).message, + ); + response.end(); + }); + deepStrictEqual( + outcome, + "Cannot set headers after they are sent to the client", + ); + ok(text.endsWith("\r\n\r\nnext")); + }); + }, +); diff --git a/packages/binden/test/send-file.test.ts b/packages/binden/test/send-file.test.ts new file mode 100644 index 00000000..11869498 --- /dev/null +++ b/packages/binden/test/send-file.test.ts @@ -0,0 +1,849 @@ +/* eslint-disable init-declarations */ +import { BindenError, type ISendFileOptions } from "../index.js"; +import { type IReply, type ITestServer, listen } from "./http.js"; +import { + after, + afterEach, + before, + beforeEach, + mock, + suite, + test, +} from "node:test"; +import { deepStrictEqual, ok } from "node:assert"; +import fsp, { + mkdir, + mkdtemp, + realpath, + rm, + stat, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { join, posix, relative, win32 } from "node:path"; +import { preconditionsOf, relativeSegments, within } from "../src/send-file.js"; +import type { IncomingMessage } from "node:http"; +import { contentTypeOf } from "../src/mime.js"; +import { kSeal } from "../src/seal.js"; +import { pathToFileURL } from "node:url"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; + +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("`304` has the length of the whole file, `412` is empty", async () => { + const file = join(root, "index.html"); + const length = `${Buffer.byteLength(html)}`; + const send = async ( + method: string, + headers: Record, + ): Promise<[number, unknown, unknown, number]> => { + const { status: code, ...reply } = await serve( + file, + {}, + { method, headers }, + ); + return [ + code, + reply.headers["content-length"], + reply.headers["content-type"], + reply.body.length, + ]; + }; + const type = "text/html; charset=utf-8"; + for (const method of ["GET", "HEAD"]) { + deepStrictEqual( + await send(method, { "If-None-Match": "*", Range: "bytes=0-1" }), + [304, length, type, 0], + method, + ); + } + // Not of the file: empty, also for `HEAD` + for (const method of ["GET", "HEAD"]) { + const [code, failed, , size] = await send(method, { "If-Match": `"x"` }); + deepStrictEqual([code, failed, size], [412, "0", 0], method); + } + }); + + test("the status set before: the preconditions of a 2xx (or 412), the ranges of a 200", async () => { + const size = `${Buffer.byteLength(html)}`; + const check = async ( + code: number, + method: string, + headers: Record = {}, + ): Promise<[number, string, string | null]> => { + const handled = server.handle(async (_request, response) => { + await response.status(code).sendFile("index.html", { root }); + }); + const reply = await server.request({ method, headers }); + deepStrictEqual(await handled, null); + return [ + reply.status, + reply.body.toString(), + reply.headers["content-length"] ?? null, + ]; + }; + const conditions: Record[] = [ + {}, + { "If-None-Match": "*" }, + { "If-Modified-Since": "Wed, 01 Jan 2031 00:00:00 GMT" }, + { "If-Match": `"nope"` }, + { "If-Unmodified-Since": "Thu, 01 Jan 1970 00:00:00 GMT" }, + { Range: "bytes=0-2" }, + ]; + // A redirect or an error (e.g. a page of an error) is sent whole with its + // status, whatever the preconditions and the ranges (RFC 9110, section + // 13.2.1), with the length of the file for `HEAD` + for (const code of [302, 404, 503]) { + for (const headers of conditions) { + const name = `${code} ${JSON.stringify(headers)}`; + deepStrictEqual( + await check(code, "GET", headers), + [code, html, size], + name, + ); + deepStrictEqual( + await check(code, "HEAD", headers), + [code, "", size], + name, + ); + } + } + // Another method with a 2xx keeps its preconditions, without ranges + for (const method of ["POST", "PUT"]) { + deepStrictEqual(await check(201, method), [201, html, size]); + deepStrictEqual(await check(201, method, { Range: "bytes=0-2" }), [ + 201, + html, + size, + ]); + for (const headers of conditions.slice(1, 5)) { + // `If-Modified-Since` applies to `GET` and `HEAD` only + const expected: [number, string, string | null] = + "If-Modified-Since" in headers ? [201, html, size] : [412, "", "0"]; + deepStrictEqual(await check(201, method, headers), expected); + } + deepStrictEqual(await check(204, method), [204, "", null]); + deepStrictEqual(await check(204, method, { "If-None-Match": "*" }), [ + 412, + "", + "0", + ]); + } + // Neither selected nor modified by `OPTIONS` and `TRACE`: their + // preconditions are ignored (RFC 9110, section 13.2.1) + for (const method of ["OPTIONS", "TRACE"]) { + for (const headers of conditions) { + deepStrictEqual( + await check(200, method, headers), + [200, html, size], + `${method} ${JSON.stringify(headers)}`, + ); + } + deepStrictEqual(await check(201, method, { "If-Match": `"x"` }), [ + 201, + html, + size, + ]); + } + // A 2xx of `GET` other than 200 keeps its preconditions, without ranges + deepStrictEqual(await check(203, "GET", { Range: "bytes=0-2" }), [ + 203, + html, + size, + ]); + deepStrictEqual(await check(203, "GET", { "If-None-Match": "*" }), [ + 304, + "", + size, + ]); + deepStrictEqual(await check(412, "GET", { "If-Match": `"nope"` }), [ + 412, + "", + "0", + ]); + deepStrictEqual(await check(200, "GET", { Range: "bytes=0-2" }), [ + 206, + html.slice(0, 3), + "3", + ]); + }); + + test("a `304` that is late writes nothing", async () => { + const handled = server.handle(async (_request, response) => { + let late = true; + const sent = response.sendFile(join(root, "index.html")); + // Sealed while the file is opened + response[kSeal](() => !late); + await sent; + late = false; + ok(!response.headersSent); + response.statusCode = 503; + response.end(); + }); + const reply = await server.request({ headers: { "If-None-Match": "*" } }); + deepStrictEqual(await handled, null); + deepStrictEqual(reply.status, 503); + }); + + 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", + ); + }); +}); + +suite("preconditionsOf", () => { + test("CONNECT, OPTIONS and TRACE ignore the conditional fields (RFC 9110, 13.2.1)", () => { + const conditions: Record[] = [ + { "if-none-match": "*" }, + { "if-match": `"x"` }, + { "if-unmodified-since": "Thu, 01 Jan 1970 00:00:00 GMT" }, + ]; + const lm = new Date("2020-01-01T00:00:00Z"); + for (const headers of conditions) { + for (const method of ["CONNECT", "OPTIONS", "TRACE"]) { + const request = { method, headers } as unknown as IncomingMessage; + deepStrictEqual(preconditionsOf(request, `"e"`, lm), null, method); + } + // The other methods evaluate them + for (const method of ["POST", "DELETE"]) { + const request = { method, headers } as unknown as IncomingMessage; + deepStrictEqual(preconditionsOf(request, `"e"`, lm), 412, method); + } + } + }); +}); diff --git a/packages/binden/test/settled-sends.test.ts b/packages/binden/test/settled-sends.test.ts new file mode 100644 index 00000000..42375661 --- /dev/null +++ b/packages/binden/test/settled-sends.test.ts @@ -0,0 +1,363 @@ +import { Binden, BindenResponse, type Context } from "../index.js"; +import { EventEmitter, once } from "node:events"; +import { type ITestServer, listen } from "./http.js"; +import { afterEach, suite, test } from "node:test"; +import { capture, listen as listenApp, send, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; +import { join } from "node:path"; +import { kCancel } from "../src/seal.js"; +import { spawnSync } from "node:child_process"; + +/** The listeners that a source has of its `data`, `end`, `error` and `close` */ +function counts(source: Readable): number[] { + return ["data", "end", "error", "close"].map((event) => + source.listenerCount(event), + ); +} + +/** The next turn: once the guard of a source that has closed is removed */ +function nextTurn(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + +/** The settled value of a promise: `null` once resolved, or its error */ +function settle(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + +/** A response of no connection */ +function detached(): BindenResponse { + return new BindenResponse(new IncomingMessage(new Socket())); +} + +/** A source that is never read to its end, whose calls of its own methods are counted */ +class Counted extends Readable { + public readonly calls: string[] = []; + + public constructor() { + super({ + read(): void { + // Its next chunk never comes + }, + }); + } + + public override destroy(error?: Error): this { + this.calls.push("destroy"); + return super.destroy(error); + } + + public override resume(): this { + this.calls.push("resume"); + return super.resume(); + } +} + +suite("A settled send of a Readable", () => { + const servers: ITestServer[] = []; + + /** Replies one request with the handler (on a server of its own) */ + const reply = async ( + handler: (response: BindenResponse) => Promise, + ): Promise => { + const app = await listen(); + servers.push(app); + const handled = app.handle(async (_request, response) => handler(response)); + const { status } = await app.request(); + deepStrictEqual(await handled, null); + return status; + }; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (app) => app.close())); + }); + + test("sent in full leaves none of its listeners on a source that the application keeps (its guard until the turn after its close)", async () => { + const source = Readable.from(["abc"]); + let settled: number[] = []; + const status = await reply(async (response) => { + await response.send(source); + settled = counts(source); + }); + deepStrictEqual(status, 200); + if (!source.closed) { + await once(source, "close"); + } + await nextTurn(); + // Of `data` and `end`: none once it has settled + deepStrictEqual( + [settled.slice(0, 2), counts(source)], + [ + [0, 0], + [0, 0, 0, 0], + ], + ); + }); + + test("of a source that has ended and closed before leaves none of its listeners (its end and its close never come)", async () => { + const source = Readable.from([]); + source.resume(); + await once(source, "close"); + const status = await reply(async (response) => { + await response.send(source); + }); + deepStrictEqual(status, 200); + await nextTurn(); + deepStrictEqual(counts(source), [0, 0, 0, 0]); + }); + + test("cancelled: a later close of the response does not touch the source, which has none of its listeners", async () => { + const response = detached(); + const source = new Counted(); + const reason = new Error("Cancelled"); + const sent = settle(response.send(source)); + response[kCancel](reason); + deepStrictEqual(await within(sent), reason); + // Released by the cancellation + ok(source.destroyed); + const calls = source.calls.length; + // E.g. the client goes away + response.emit("close"); + await nextTurn(); + deepStrictEqual( + [source.calls.slice(calls), counts(source).slice(0, 2)], + [[], [0, 0]], + ); + }); + + test("failed once its first chunk is written: a later drain of the response does not resume the source", async () => { + const response = detached(); + const source = new Counted(); + // More than the response takes before it is full + source.push(Buffer.alloc(64 * 1024, 0x61)); + const failure = new Error("Failed"); + const sent = settle(response.send(source)); + await within(once(source, "pause")); + ok(response.headersSent); + source.destroy(failure); + deepStrictEqual(await within(sent), failure); + const calls = source.calls.length; + response.emit("drain"); + response.emit("close"); + await nextTurn(); + deepStrictEqual( + [source.calls.slice(calls), counts(source).slice(0, 2)], + [[], [0, 0]], + ); + }); + + test("of a source still read once another writer has ended the response releases it", async () => { + // Its next chunk never comes: the response closes before the send + // settles, or else the settlement releases it + const source = new Counted(); + source.push("first"); + const status = await reply(async (response) => { + // As a compression that replaces the response with an empty `406` + // when the first chunk commits the headers + response.write = (): boolean => { + response.writeHead(406, { "Content-Length": 0 }).end(); + return true; + }; + await response.send(source); + }); + deepStrictEqual([status, source.destroyed], [406, true]); + }); + + test("of a source destroyed by the application before rejects at once: only its guard listens, until the turn after its close", async () => { + const closed = Readable.from(["abc"]); + closed.destroy(); + await once(closed, "close"); + // Its destroy completes later + let complete = (): void => { + // Until it is destroyed + }; + const closing = new Readable({ + read(): void { + // Nothing to read + }, + destroy(error, callback): void { + complete = (): void => { + callback(error); + }; + }, + }); + closing.destroy(); + const outcomes: unknown[] = []; + for (const source of [closed, closing]) { + const status = await reply(async (response) => { + const error = await within(settle(response.send(source)), 1000); + outcomes.push((error as { code?: unknown }).code); + await response.status(500).send(); + }); + deepStrictEqual(status, 500); + } + await nextTurn(); + deepStrictEqual( + [outcomes, counts(closed), counts(closing)], + [ + ["ERR_STREAM_PREMATURE_CLOSE", "ERR_STREAM_PREMATURE_CLOSE"], + [0, 0, 0, 0], + [0, 0, 1, 1], + ], + ); + complete(); + await once(closing, "close"); + await nextTurn(); + deepStrictEqual(counts(closing), [0, 0, 0, 0]); + }); + + test("failed by an error that does not destroy the source releases it", async () => { + const source = new Counted(); + let error: unknown = null; + const failure = new Error("Failed"); + const status = await reply(async (response) => { + setTimeout(() => { + source.emit("error", failure); + }, 10); + error = await settle(response.send(source)); + ok(!response.started); + await response.status(500).send(); + }); + deepStrictEqual([status, error, source.destroyed], [500, failure, true]); + }); + + test("once it has failed, a listener that the source keeps beyond its removal (its own on()) changes nothing: the error is replied", async () => { + const kept: (() => void)[] = []; + const source = new Counted(); + Object.assign(source, { + on(event: string, listener: () => void): Counted { + if (event === "end") { + kept.push(listener); + return source; + } + return Readable.prototype.on.call(source, event, listener) as Counted; + }, + }); + const failure = new Error("Failed"); + let error: unknown = null; + const status = await reply(async (response) => { + setTimeout(() => { + source.emit("error", failure); + }, 10); + error = await settle(response.send(source)); + // Its end, which the send no longer waits for + for (const listener of kept) { + listener(); + } + ok(!response.started); + await response.status(500).send(); + }); + deepStrictEqual([status, error, kept.length], [500, failure, 1]); + }); + + for (const [own, removal] of [ + ["`off()`", { removeListener: null }], + ["neither", { removeListener: null, off: null }], + ] as const) { + test(`whose own \`removeListener()\` is not a function: its listeners are removed by ${own === "neither" ? "the methods of `EventEmitter` when its own `off()` is not one either" : `its own ${own}`}`, async () => { + const response = detached(); + const source = new Counted(); + const removed: string[] = []; + Object.assign(source, removal); + if (own !== "neither") { + Object.assign(source, { + off(event: string, listener: () => void): Counted { + removed.push(event); + return EventEmitter.prototype.removeListener.call( + source, + event, + listener, + ) as Counted; + }, + }); + } + const reason = new Error("Cancelled"); + const sent = settle(response.send(source)); + deepStrictEqual(counts(source).slice(0, 2), [1, 1]); + response[kCancel](reason); + deepStrictEqual(await within(sent), reason); + // Only its guard, until the turn after its close + deepStrictEqual( + [removed.sort(), counts(source)], + [ + own === "neither" ? [] : ["close", "data", "end", "error"], + [0, 0, 1, 1], + ], + ); + }); + } + + test("whose own `removeListener()` throws: each failure is logged at `debug`, and its guard and the listeners of the send that it keeps leave a later error harmless", async () => { + const captured = capture(); + const failure = new Error("Not removed"); + const source = Readable.from(["abc"]); + Object.assign(source, { + removeListener(): never { + throw failure; + }, + }); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + outcome.resolve(await settle(context.send(source))); + }, + ); + const listening = await listenApp(app); + try { + const { status, body } = await send(listening.port); + deepStrictEqual( + [status, body, await within(outcome.promise)], + [200, "abc", null], + ); + const message = "The removal of a listener of a stream has failed"; + deepStrictEqual( + captured.records + .filter(({ msg }) => msg === message) + .map(({ level }) => level), + [20, 20, 20, 20], + ); + if (!source.closed) { + await once(source, "close"); + } + await nextTurn(); + // Only those of the send, which ignore what it emits once it has + // settled (its guard is removed once it has closed) + deepStrictEqual(counts(source), [1, 1, 1, 1]); + source.emit("error", new Error("Late")); + } finally { + await listening.close(); + } + }); + + test("the late errors of the destroy of a source (destroyed by the application before the send, or by autoDestroy once it has ended), without a listener of the application, are logged and do not crash the process", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "native-late-errors.ts"), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual( + { status, stdout, stderr }, + { + status: 0, + stdout: + "500 200 200\nERR_STREAM_PREMATURE_CLOSE resolved resolved\n" + + "Failed later (/active), Failed later (/destroyed), Failed later (/ended)\n" + + "0000 0000 0000\n", + stderr: "", + }, + ); + }); +}); diff --git a/packages/binden/test/setup.ts b/packages/binden/test/setup.ts new file mode 100644 index 00000000..2898638f --- /dev/null +++ b/packages/binden/test/setup.ts @@ -0,0 +1,6 @@ +import http from "node:http"; + +// 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/binden/test/shutdown.test.ts b/packages/binden/test/shutdown.test.ts new file mode 100644 index 00000000..f543a7b7 --- /dev/null +++ b/packages/binden/test/shutdown.test.ts @@ -0,0 +1,695 @@ +import { + Binden, + BindenAbortError, + BindenRequest, + BindenResponse, + type Context, + isClosing, + shutdown, +} from "../index.js"; +import { type Server, createServer } from "node:http"; +import { type Socket, connect } from "node:net"; +import { + capture, + listen, + never, + pollUntil, + send, + sleep, + within, +} from "./helpers.js"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { mock, suite, test } from "node:test"; +import dns from "node:dns"; +import { once } from "node:events"; + +/** 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, "127.0.0.1"); + 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 => { + await pollUntil(() => data.includes(text) || socket.destroyed).catch( + () => null, + ); + if (!data.includes(text)) { + throw new Error(`Not received: ${text} (${data})`); + } + }, + 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("answers the pipelined requests in progress, and closes the connection after the last one", async () => { + const gates = { "/a": gate(), "/b": gate() }; + const started: string[] = []; + const app = new Binden().use(async (context: Context) => { + const { pathname } = context.url; + started.push(pathname); + await gates[pathname as keyof typeof gates].promise; + await context.send(pathname); + }); + 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\nGET /b HTTP/1.1\r\nHost: localhost\r\n\r\n", + ); + await pollUntil(() => started.length >= 2); + + const closing = shutdown(server); + gates["/b"].open(); + await sleep(20); + gates["/a"].open(); + await within(closing); + await within(client.closed); + const [first = "", second = ""] = client + .received() + .split(/(?=HTTP\/1\.1 )/u); + // The first response keeps the connection open for the second one + ok(first.startsWith("HTTP/1.1 200 OK"), first); + ok(/\r\nConnection: keep-alive\r\n/iu.test(first), first); + ok(first.endsWith("/a"), first); + ok(second.startsWith("HTTP/1.1 200 OK"), second); + ok(/\r\nConnection: close\r\n/iu.test(second), second); + ok(second.endsWith("/b"), second); + }); + + test("a request that arrives once closing keeps the connection open for its `503`", async () => { + const gates = { "/a": gate(), "/b": gate() }; + const started: string[] = []; + const app = new Binden().use(async (context: Context) => { + const { pathname } = context.url; + started.push(pathname); + await gates[pathname as keyof typeof gates].promise; + await context.send(pathname); + }); + const { server, port } = await listen(app); + const requests: string[] = []; + server.on("request", (request: BindenRequest) => { + requests.push(request.url ?? ""); + }); + const client = await connection(port); + client.socket.write( + "GET /a HTTP/1.1\r\nHost: localhost\r\n\r\nGET /b HTTP/1.1\r\nHost: localhost\r\n\r\n", + ); + await pollUntil(() => started.length >= 2); + + const closing = shutdown(server); + // The newest request of the connection once the server is closing + client.socket.write("GET /c HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await pollUntil(() => requests.length >= 3); + gates["/a"].open(); + gates["/b"].open(); + await within(closing); + await within(client.closed); + const replies = client.received().split(/(?=HTTP\/1\.1 )/u); + deepStrictEqual( + replies.map((reply) => [ + reply.split("\r\n", 1)[0], + /\r\nConnection: (?[\w-]+)\r\n/iu.exec(reply)?.groups?.value, + ]), + [ + ["HTTP/1.1 200 OK", "keep-alive"], + ["HTTP/1.1 200 OK", "keep-alive"], + ["HTTP/1.1 503 Service Unavailable", "close"], + ], + ); + deepStrictEqual(started, ["/a", "/b"]); + }); + + 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"), + ); + const record = await captured.waitFor("The shutdown has failed"); + deepStrictEqual(record.failure, { type: "Error", location: "shutdown" }); + }); + + test("frames the connection of the reply of a deadline, whose response is sealed", async () => { + for (const pipelined of [false, true]) { + const handled = gate(); + const app = new Binden({ + timeout: 20, + error_handler: async (context: Context): Promise => { + await handled.promise; + await context.status(503).send("deadline"); + }, + }).use(async () => { + await never(); + }); + 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"); + // Past the deadline: the response is sealed, the error handler waits + await sleep(60); + const closing = shutdown(server); + if (pipelined) { + // It arrives once closing: the reply before it keeps the connection + client.socket.write("GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await sleep(20); + } + handled.open(); + await within(closing); + await within(client.closed); + const [first = "", second = ""] = client + .received() + .split(/(?=HTTP\/1\.1 )/u); + ok(first.startsWith("HTTP/1.1 503 "), first); + if (pipelined) { + ok(/\r\nConnection: keep-alive\r\n/iu.test(first), first); + ok(second.startsWith("HTTP/1.1 503 "), second); + ok(/\r\nConnection: close\r\n/iu.test(second), second); + } else { + ok(/\r\nConnection: close\r\n/iu.test(first), first); + } + } + }); + + 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("a server that is about to listen is closed once it listens", async () => { + const app = new Binden().use((context: Context) => context.send("ok")); + const server = app.createServer(); + const listeners = ["listening", "close", "error"].map((name) => + server.listenerCount(name), + ); + // Bound once the address has been looked up (on the next tick) + server.listen(0, "127.0.0.1"); + ok(!server.listening); + const bound = once(server, "listening"); + try { + await within(shutdown(server)); + await bound; + ok(!server.listening); + deepStrictEqual( + ["listening", "close", "error"].map((name) => + server.listenerCount(name), + ), + listeners, + ); + } finally { + // Not left listening by a failure + server.close(); + } + }); + + test("a `listen()` that has not completed within `timeout` is cancelled", async () => { + const lookups: (() => void)[] = []; + // The address of the host is looked up once the shutdown has given up + mock.method(dns, "lookup", (...args: unknown[]): void => { + const callback = args.at(-1) as ( + error: null, + address: string, + family: number, + ) => void; + lookups.push(() => { + callback(null, "127.0.0.1", 4); + }); + }); + const captured = capture(); + const app = new Binden({ logger: captured.logger }); + const server = app.createServer(); + const listeners = ["listening", "close", "error"].map((name) => + server.listenerCount(name), + ); + try { + server.listen(0, "binden.test"); + ok(!server.listening); + const start = performance.now(); + await rejects( + within(shutdown(server, { timeout: 50 })), + new Error("The server has not started listening within 50 ms"), + ); + ok(performance.now() - start < 1000); + deepStrictEqual( + ["listening", "close", "error"].map((name) => + server.listenerCount(name), + ), + listeners, + ); + await captured.waitFor("The shutdown has failed"); + // Looked up now: the server does not listen + deepStrictEqual(lookups.length, 1); + for (const lookup of lookups) { + lookup(); + } + await sleep(20); + ok(!server.listening); + } finally { + mock.restoreAll(); + server.close(); + } + }); + + test("the `timeout` covers the wait for a pending `listen()` and the drain", async () => { + const aborted = Promise.withResolvers(); + const app = new Binden().use(async (context: Context) => { + context.signal.addEventListener("abort", () => { + aborted.resolve(performance.now()); + }); + await never(); + }); + const { server, port } = await listen(app); + // A request in progress on a connection of a server that listens again + const client = await connection(port); + client.socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await sleep(20); + server.close(); + mock.method(dns, "lookup", (...args: unknown[]): void => { + const callback = args.at(-1) as ( + error: null, + address: string, + family: number, + ) => void; + // Looked up after 2/3 of the `timeout` + setTimeout(callback, 200, null, "127.0.0.1", 4); + }); + try { + server.listen(0, "binden.test"); + const start = performance.now(); + await within(shutdown(server, { timeout: 300 })); + // Aborted at the `timeout` of the whole shutdown, not of the drain + const elapsed = (await aborted.promise) - start; + ok(elapsed >= 250 && elapsed < 450, `${elapsed} ms`); + } finally { + mock.restoreAll(); + client.socket.destroy(); + server.close(); + } + }); + + test("a server whose `listen()` fails rejects the shutdown with its error", async () => { + const busy = createServer(); + busy.listen(0, "127.0.0.1"); + await once(busy, "listening"); + const { port } = busy.address() as { port: number }; + const captured = capture(); + const app = new Binden({ logger: captured.logger }); + const server = app.createServer(); + const listeners = ["listening", "close", "error"].map((name) => + server.listenerCount(name), + ); + try { + server.listen(port, "127.0.0.1"); + // Without an `error` listener of the application: the shutdown has it + await rejects(within(shutdown(server)), { code: "EADDRINUSE" }); + ok(isClosing(server) && !server.listening); + deepStrictEqual( + ["listening", "close", "error"].map((name) => + server.listenerCount(name), + ), + listeners, + ); + const record = await captured.waitFor("The shutdown has failed"); + deepStrictEqual(record.failure, { + type: "Error", + code: "EADDRINUSE", + location: "shutdown", + }); + } finally { + busy.close(); + } + }); + + test("a `listen()` that has failed, thrown or been cancelled is not waited for", async () => { + const busy = createServer(); + busy.listen(0, "127.0.0.1"); + await once(busy, "listening"); + const { port } = busy.address() as { port: number }; + const app = new Binden(); + try { + const failed = app.createServer(); + failed.listen(port, "127.0.0.1"); + await once(failed, "error"); + await within(shutdown(failed)); + + const thrown = app.createServer(); + throws(() => thrown.listen(-1), { code: "ERR_SOCKET_BAD_PORT" }); + await within(shutdown(thrown)); + + const cancelled = app.createServer(); + cancelled.listen(0, "127.0.0.1"); + cancelled.close(); + await once(cancelled, "close"); + await within(shutdown(cancelled)); + ok(!cancelled.listening); + } finally { + busy.close(); + } + }); + + test("an `error` of a server without a listener of its own still throws", () => { + const server = new Binden().createServer(); + const error = new Error("An error of the server"); + throws(() => server.emit("error", error), error); + }); + + 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, "127.0.0.1"); + 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(); + // 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, "127.0.0.1"); + 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 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)); + server.listen(0, "127.0.0.1"); + 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 fail = (): never => { + throw new Error("A failing logger"); + }; + const failing = new Binden({ + logger: { + fatal: fail, + error: fail, + warn: fail, + info: fail, + debug: fail, + trace: fail, + child: fail, + }, + }); + const server = createServer(); + server.on("request", failing.callback(server)); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as { port: number }; + try { + await rejects(send(port), { code: "ECONNRESET" }); + } finally { + server.close(); + } + }); +}); diff --git a/packages/binden/test/source-decisions.test.ts b/packages/binden/test/source-decisions.test.ts new file mode 100644 index 00000000..0177b8cc --- /dev/null +++ b/packages/binden/test/source-decisions.test.ts @@ -0,0 +1,153 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { capture, ioTurn, listen, pollUntil, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** How a send has settled: resolved, or the message of its error */ +function outcomeOf(sent: Promise): Promise { + return sent.then( + () => "resolved", + (error: unknown) => `rejected:${(error as Error).message}`, + ); +} + +/** The messages of the late errors (and of the release failures) that have been logged */ +function logged(records: Record[]): unknown[] { + return records + .filter( + ({ msg }) => + msg === releasedFailure || msg === "The release of a stream has failed", + ) + .map( + ({ msg, failure }) => + `${String(msg)}: ${String((failure as { message?: unknown }).message)}`, + ); +} + +suite( + "The send of a Node.js `Readable` destroyed with an error before it", + () => { + for (const [how, emits] of [ + ["emits that error", true], + ["throws that error", false], + ] as const) { + test(`rejects with it, which is decided while its guard is installed: code of the source that ${how} meanwhile (for the listener of \`close\` of the guard) is not logged, and another error is`, async () => { + for (const same of [true, false]) { + const failure = new Error("Destroyed with it"); + const other = new Error("Another error"); + const captured = capture(); + const outcomes: string[] = []; + const listening = await listen( + new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const source = new Readable({ + read(): void { + // Never read + }, + }); + // Its `error` event comes on the next tick + source.destroy(failure); + source.on("error", () => null); + let armed = true; + source.on("newListener", (event: string) => { + if (armed && event === "close") { + armed = false; + const error = same ? failure : other; + if (emits) { + source.emit("error", error); + } else { + throw error; + } + } + }); + outcomes.push(await outcomeOf(context.send(source))); + throw new Error("Replied"); + }), + ); + try { + const { status } = await send(listening.port); + await ioTurn(); + const expected = emits + ? `${releasedFailure}: ${other.message}` + : `The release of a stream has failed: ${other.message}`; + deepStrictEqual( + [status, outcomes, logged(captured.records)], + [500, [`rejected:${failure.message}`], same ? [] : [expected]], + ); + } finally { + await listening.close(); + } + } + }); + } + }, +); + +suite("An error of the application that decides the send of one source", () => { + for (const [how, when] of [ + ["emitted while it is sent", "sent"], + ["that it has been destroyed with before its send", "destroyed"], + ] as const) { + test(`(${how}) is still logged once as a late error of another source (released to \`HEAD\`), never of the source whose send it has decided`, async () => { + // E.g. one error that the application destroys each of its streams with + const failure = new Error("Shut down"); + const captured = capture(); + const outcomes: Record = {}; + const listening = await listen( + new Binden({ logger: captured.logger, log_error_details: true }).use( + async (context: Context) => { + if (context.request.method === "HEAD") { + // Released at once, its destroy fails later + const released = new Readable({ + read(): void { + // Never read + }, + destroy(_error, callback): void { + setTimeout(callback, 50, failure); + }, + }); + outcomes.released = await outcomeOf(context.send(released)); + return; + } + const source = new Readable({ + read(): void { + // Never read + }, + }); + if (when === "destroyed") { + source.destroy(failure); + } else { + setImmediate(() => { + source.destroy(failure); + }); + } + outcomes.decided = await outcomeOf(context.send(source)); + throw new Error("Replied"); + }, + ), + ); + try { + const [released, decided] = await Promise.all([ + send(listening.port, { method: "HEAD" }), + send(listening.port), + ]); + await pollUntil(() => logged(captured.records).length > 0); + await ioTurn(); + deepStrictEqual( + [released.status, decided.status, outcomes, logged(captured.records)], + [ + 200, + 500, + { released: "resolved", decided: `rejected:${failure.message}` }, + [`${releasedFailure}: ${failure.message}`], + ], + ); + } finally { + await listening.close(); + } + }); + } +}); diff --git a/packages/binden/test/source-failures.test.ts b/packages/binden/test/source-failures.test.ts new file mode 100644 index 00000000..de1ec9d4 --- /dev/null +++ b/packages/binden/test/source-failures.test.ts @@ -0,0 +1,240 @@ +import { Binden, type Context } from "../index.js"; +import { capture, listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** How a send has settled: `resolved`, or what it has rejected with */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => error, + ); +} + +/** + * Replies one request with the middleware: the status that the client gets + * (`0` once the connection is closed before), what the middleware has seen, + * and the logger (with the messages of the errors). The middleware then + * throws: its error is replied (`500`), or the connection is closed once the + * headers are committed. + */ +async function replied( + middleware: (context: Context) => Promise, +): Promise<[number, T, ReturnType]> { + const captured = capture(); + const seen = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + seen.resolve(await middleware(context)); + throw new Error("Replied"); + }); + const listening = await listen(app); + try { + const status = await send(listening.port, { ms: 10_000 }).then( + (received) => received.status, + () => 0, + ); + return [status, await within(seen.promise, 10_000), captured]; + } finally { + await listening.close(); + } +} + +suite("Code of a source that throws while send() reads it", () => { + test("its own `on()`, a listener of its `newListener`, its `resume()` (fresh, flowing, paused, at a drain), whatever it throws: the send rejects with it, and nothing crashes or is left", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "throwing-sources.ts"), + ], + { encoding: "utf8", timeout: 60_000 }, + ); + const lines: string[] = []; + for (const [path, reply] of [ + ["fresh", "started=false 500+200(open)"], + ["flowing", "started=false 500+200(open)"], + ["paused", "started=false 500+200(open)"], + ["on", "started=false 500+200(open)"], + ["newListener", "started=false 500+200(open)"], + // The headers are committed: the connection is closed + ["drain", "started=true (closed)"], + ["backpressure", "started=true (closed)"], + ] as const) { + for (const value of ["error", "null", "undefined", "string", "number"]) { + lines.push( + `${path}/${value} rejected ${reply} destroyed=true listeners=0`, + ); + } + } + deepStrictEqual( + { status, stdout, stderr }, + { status: 0, stdout: `${lines.join("\n")}\n`, stderr: "" }, + ); + }); + + test("an `error` that its own `on()` emits at once settles the send: no other listener is added, and it is not resumed", async () => { + const failure = new Error("Emitted at once"); + const added: unknown[] = []; + let resumes = 0; + const source = new (class extends Readable { + public override on(...args: Parameters): this { + const [event] = args; + added.push(event); + super.on(...args); + if (event === "error") { + this.emit("error", failure); + } + return this; + } + + public override resume(): this { + resumes += 1; + return super.resume(); + } + })({ + read(): void { + // Never read + }, + }); + source.pause(); + const [status, outcome] = await replied(async (context) => { + const error = await outcomeOf(context.send(source)); + return [error, context.response.started]; + }); + deepStrictEqual( + [status, outcome, added, resumes, source.destroyed], + // Its `error` first: nothing else once it has failed + [500, [failure, false], ["error"], 0, true], + ); + }); + + test("a guard that cannot be installed (a listener of its `newListener` that throws) is logged, and the send still settles", async () => { + const failure = new Error("No close listener"); + // Another error than the one that decides the send (which is not logged + // again, see `guard-decisions.test.ts`) + const refusal = new Error("No close listener of the guard"); + let closes = 0; + const source = new Readable({ + read(): void { + // Never read + }, + }); + source.on("newListener", (event: string) => { + if (event === "close") { + closes += 1; + throw closes === 1 ? failure : refusal; + } + }); + const [status, outcome, captured] = await replied(async (context) => { + const error = await outcomeOf(context.send(source)); + return [error, context.response.started]; + }); + const record = await captured.waitFor("The release of a stream has failed"); + deepStrictEqual( + [ + status, + outcome, + record.level, + (record.failure as { message?: unknown }).message, + source.destroyed, + ], + [500, [failure, false], 20, refusal.message, true], + ); + // Only the `error` listener of its guard + deepStrictEqual( + ["data", "end", "error", "close"].map((event) => + source.listenerCount(event), + ), + [0, 0, 1, 0], + ); + }); +}); + +suite("A send that settles otherwise", () => { + /** + * A source that pushes a chunk once read, never ends, and records how many + * `data` listeners it has when it is destroyed + */ + function recording(counts: number[]): Readable { + let pushed = false; + return new (class extends Readable { + public override destroy(error?: Error): this { + counts.push(this.listenerCount("data")); + return super.destroy(error); + } + })({ + read(): void { + if (!pushed) { + pushed = true; + this.push("chunk"); + } + }, + }); + } + + test("once its source has ended without being destroyed (`autoDestroy: false`): it resolves, and leaves the source as it is", async () => { + const source = new Readable({ + autoDestroy: false, + read(): void { + this.push("ended"); + this.push(null); + }, + }); + const [status, outcome] = await replied(async (context) => { + const sent = await outcomeOf(context.send(source)); + return [sent, source.readableEnded]; + }); + // The middleware then throws: once the response has ended, it is logged + deepStrictEqual( + [status, outcome, source.destroyed], + [200, ["resolved", true], false], + ); + }); + + test("by an `error` of the response: it rejects with it, then releases its source", async () => { + const counts: number[] = []; + const failure = new Error("The response has failed"); + const source = recording(counts); + source.pause(); + const [status, outcome] = await replied(async (context) => { + // Waits for the drain of the response + context.response.write(Buffer.alloc(1 << 20, 0x61)); + const sent = outcomeOf(context.send(source)); + context.response.emit("error", failure); + return sent; + }); + // Its listeners removed before it is destroyed + deepStrictEqual( + [status, outcome, counts, source.destroyed], + [0, failure, [0], true], + ); + }); + + test("by a write that is not delivered: it rejects with its error, then releases its source", async () => { + const counts: number[] = []; + const failure = new Error("Not delivered"); + const source = recording(counts); + const [status, outcome] = await replied(async (context) => { + const { response } = context; + const write = response.write.bind(response); + Object.defineProperty(response, "write", { + value: (chunk: string, callback: (error: Error) => void): boolean => { + setImmediate(callback, failure); + return write(chunk); + }, + }); + return outcomeOf(context.send(source)); + }); + deepStrictEqual( + [status, outcome, counts, source.destroyed], + [0, failure, [0], true], + ); + }); +}); diff --git a/packages/binden/test/stale-stacks.test.ts b/packages/binden/test/stale-stacks.test.ts new file mode 100644 index 00000000..3b6ecb0d --- /dev/null +++ b/packages/binden/test/stale-stacks.test.ts @@ -0,0 +1,234 @@ +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, + uncarried, +} from "../src/stream-like.js"; +import { ioTurn, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** Redefines the `stack` of an error as a getter that throws */ +function unstack(error: Error): void { + Object.defineProperty(error, "stack", { + configurable: true, + get(): never { + throw new Error("Its stack has been redefined"); + }, + }); +} + +/** + * Runs `call` while the next read of the state of a writable on a `Readable` + * (Node.js reads it as it destroys one, before it reads the stack of the + * error) redefines the `stack` of `error`, once: the verdict that binden has + * reached on it just before is stale then, whatever it has checked + */ +function staleDuring(error: Error, call: () => void): void { + Object.defineProperty(Readable.prototype, "_writableState", { + configurable: true, + get(): void { + Reflect.deleteProperty(Readable.prototype, "_writableState"); + unstack(error); + }, + }); + try { + call(); + } finally { + Reflect.deleteProperty(Readable.prototype, "_writableState"); + } +} + +/** + * A stream-like source of its own whose `resume()` (the first read of its + * readable) throws its value, and that records what it is destroyed with: its + * `destroy()` redefines the stack of the error that it is given (`redefine`), + * or throws (`throws`), before it would set `destroyed` + */ +class Source extends EventEmitter { + public readonly value = new Error("V"); + public readonly destroys: unknown[] = []; + readonly #destroying: "redefine" | "throws" | null; + + public constructor(destroying: "redefine" | "throws" | null = null) { + super(); + this.#destroying = destroying; + } + + public pause(): this { + return this; + } + + public resume(): never { + throw this.value; + } + + public destroy(error?: Error): this { + this.destroys.push(error); + if (this.#destroying === "throws") { + throw new Error("Its destroy has failed"); + } else if (this.#destroying === "redefine" && error instanceof Error) { + unstack(error); + } + return this; + } +} + +/** The readable of a source, and the failures of its release that it logs */ +function readableOf(source: Source): { + readonly readable: Readable; + readonly released: unknown[]; +} { + const released: unknown[] = []; + const log = (error: unknown): void => { + released.push(error); + }; + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + () => null, + { released: log, removed: log }, + ); + return { readable, released }; +} + +/** The error that a readable emits, and whether it has closed after it */ +async function failureOf(readable: Readable): Promise<[unknown, boolean]> { + const error = await within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); + await ioTurn(); + return [error, readable.closed]; +} + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, with arguments, + * without a handler of the process: a crash (e.g. a throw into an event of a + * source) is its exit code and output, not a failure of the test runner + */ +function fixture(name: string, ...args: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ...args, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite( + "A stream-like source whose value reads safely when binden checks it, but no longer once Node.js reads it (H24-1)", + () => { + test("redefined as Node.js destroys the readable, before it reads the stack: the readable is destroyed once more, with a carrier, and the source once, with that carrier", async () => { + const source = new Source(); + const { readable, released } = readableOf(source); + const failure = failureOf(readable); + staleDuring(source.value, () => { + // Its first read, at once + readable.read(); + }); + const [carrier, closed] = (await failure) as [Error, boolean]; + deepStrictEqual( + [ + carrier.message, + carrier.cause === source.value, + uncarried(carrier, readable) === source.value, + readable.errored === carrier, + closed, + source.destroys.length, + source.destroys[0] === carrier, + released, + ], + ["A stream has failed", true, true, true, true, 1, true, []], + ); + }); + + test("redefined by the destroy() of the source, given it: the readable emits its error and its close all the same, with a carrier for that value", async () => { + const source = new Source("redefine"); + const { readable, released } = readableOf(source); + const failure = failureOf(readable); + readable.resume(); + const [carrier, closed] = (await failure) as [Error, boolean]; + deepStrictEqual( + [ + carrier.message, + carrier.cause === source.value, + uncarried(carrier, readable) === source.value, + // As Node.js has read it first + readable.errored === source.value, + closed, + source.destroys.length, + source.destroys[0] === source.value, + released, + ], + ["A stream has failed", true, true, true, true, 1, true, []], + ); + }); + + test("whose destroy() throws before it would set destroyed, also once the readable is destroyed once more: it is called once, what it throws is logged, and the readable emits the value", async () => { + for (const stale of [false, true]) { + const source = new Source("throws"); + const { readable, released } = readableOf(source); + const failure = failureOf(readable); + if (stale) { + staleDuring(source.value, () => { + readable.read(); + }); + } else { + readable.resume(); + } + const [error, closed] = await failure; + deepStrictEqual( + [ + uncarried(error, readable) === source.value, + closed, + source.destroys.length, + uncarried(source.destroys[0], readable) === source.value, + released.map((logged) => (logged as Error).message), + ], + [true, true, 1, true, ["Its destroy has failed"]], + String(stale), + ); + } + }); + + test("sent, without a handler of the process: the send rejects with the value at once, the source is destroyed once, and nothing is thrown into its events", () => { + const ways = ["emit", "pause", "resume"]; + for (const kind of ["name", "pst", "proto", "redefine"]) { + // Emitted, the value has reached the source already: it is + // destroyed without it + const destroyed = + kind === "redefine" + ? ["none", "V", "V"] + : ["none", "carrier", "carrier"]; + deepStrictEqual( + fixture("stale-stacks.ts", kind, ...ways), + { + status: 0, + stdout: ways + .map( + (way, index) => + `${kind}:${way} ${way === "pause" ? 200 : 500} rejected:V destroys=${destroyed[index]} threw= late=Later\n`, + ) + .join(""), + stderr: "", + }, + kind, + ); + } + }); + }, +); diff --git a/packages/binden/test/stopped-decisions.test.ts b/packages/binden/test/stopped-decisions.test.ts new file mode 100644 index 00000000..ccd4246e --- /dev/null +++ b/packages/binden/test/stopped-decisions.test.ts @@ -0,0 +1,88 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { capture, ioTurn, listen, within } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { suite, test } from "node:test"; +import { PassThrough } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** The message of the failure of a release of a source */ +const releaseFailure = "The release of a stream has failed"; + +/** + * Sends a source to a response that the application ends while its send + * listens (it waits for the response before it on its connection, `/slow`, + * so that it has not finished): then the source emits an error, and a + * listener of its `newListener` throws that same error for the listener of + * `event` that the guard of the stopped send adds. Resolves with how the + * send has settled, and the messages of the late errors and of the failed + * releases that have been logged + */ +async function stopped( + event: "close" | "error", +): Promise<[string, unknown[], unknown[]]> { + const failure = new Error("Emitted, then thrown for the guard"); + const captured = capture(); + const slow = Promise.withResolvers(); + const outcome = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + if (context.url.pathname === "/slow") { + await slow.promise; + await context.text("slow"); + return; + } + const source = new PassThrough(); + const sent = context.send(source).then( + () => "resolved", + (error: unknown) => `rejected:${(error as Error).message}`, + ); + context.response.end("complete"); + setImmediate(() => { + let armed = true; + source.on("newListener", (name: string) => { + if (armed && name === event) { + armed = false; + throw failure; + } + }); + source.emit("error", failure); + slow.resolve(null); + }); + outcome.resolve(await sent); + }); + const listening = await listen(app); + try { + await converse(listening.port, `${get("/slow")}${get("/ended")}`, { + complete: (received) => received.endsWith("\r\n\r\ncomplete"), + }); + const settled = await within(outcome.promise); + // The report of a stopped send comes on a later turn + await ioTurn(); + const messages = (message: string): unknown[] => + captured.records + .filter(({ msg }) => msg === message) + .map( + ({ failure: logged }) => (logged as { message?: unknown }).message, + ); + return [settled, messages(releasedFailure), messages(releaseFailure)]; + } finally { + await listening.close(); + } +} + +suite( + "The error of a source that has stopped its send (the response has ended), which code of the source throws again for a listener of the guard that the send installs", + () => { + for (const event of ["error", "close"] as const) { + test(`is logged once, as a late error, not as a refusal of the guard as well (for its listener of \`${event}\`)`, async () => { + const [settled, late, refusals] = await stopped(event); + deepStrictEqual( + [settled, late, refusals], + ["resolved", ["Emitted, then thrown for the guard"], []], + ); + }); + } + }, +); diff --git a/packages/binden/test/stopped-sends.test.ts b/packages/binden/test/stopped-sends.test.ts new file mode 100644 index 00000000..8ee2657c --- /dev/null +++ b/packages/binden/test/stopped-sends.test.ts @@ -0,0 +1,171 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { capture, ioTurn, listen, within } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { suite, test } from "node:test"; +import type { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { createRequire } from "node:module"; +import { deepStrictEqual } from "node:assert/strict"; + +const require = createRequire(import.meta.url); + +/** A `PassThrough` of `readable-stream` 4 (a stream-like source) */ +type IPassThrough = new (options?: object) => EventEmitter & { + destroy(error?: Error): unknown; +}; + +const { PassThrough: StreamLike } = require("readable-stream-v4") as { + PassThrough: IPassThrough; +}; + +/** The source shared by both responses, and how it fails */ +interface IShared { + readonly source: EventEmitter; + fail(error: Error): void; +} + +/** + * Sends one source to two responses: `/ended`, which the application ends + * while its send listens (it waits for the response before it on its + * connection, `/slow`, so that it has not finished), and `/live`, whose send + * the source then fails (`first`: whose send listens first). Resolves with + * the bodies of `/ended` and `/live`, how their sends have settled, and the + * late errors that have been logged + */ +async function shared( + sharing: IShared, + first: "ended" | "live", +): Promise<[string[], Record, unknown[]]> { + const failure = new Error("The shared source has failed"); + const captured = capture(); + const outcomes: Record = {}; + const started = { + ended: Promise.withResolvers(), + live: Promise.withResolvers(), + }; + const slow = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/slow") { + await slow.promise; + await context.text("slow"); + return; + } + // @ts-expect-error -- a stream-like source, or a Readable + const sent = context.send(sharing.source).then( + () => "resolved", + (error: unknown) => `rejected:${(error as Error).message}`, + ); + if (pathname === "/ended") { + context.response.end("complete"); + started.ended.resolve(null); + outcomes.ended = await sent; + } else { + started.live.resolve(null); + outcomes.live = await sent; + context.response.status(500).end("failed"); + } + }); + const listening = await listen(app); + try { + const conversations: Promise<{ received: string }>[] = []; + for (const name of first === "ended" + ? ["ended", "live"] + : ["live", "ended"]) { + conversations.push( + name === "ended" + ? converse(listening.port, `${get("/slow")}${get("/ended")}`, { + complete: (received) => received.endsWith("\r\n\r\ncomplete"), + }) + : converse(listening.port, get("/live"), { + complete: (received) => received.endsWith("\r\n\r\nfailed"), + }), + ); + await within(started[name as "ended" | "live"].promise); + } + sharing.fail(failure); + // Once the error has reached every listener of the source + await ioTurn(); + slow.resolve(null); + const received = await Promise.all(conversations); + if (first === "live") { + received.reverse(); + } + // The report of a stopped send comes on a later turn + await ioTurn(); + const bodies = received.map( + ({ received: text }) => + /\r\n\r\n(?\w+)$/u.exec(text)?.groups?.body ?? "", + ); + const late = captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure: logged }) => (logged as { message?: unknown }).message); + return [bodies, outcomes, late]; + } finally { + await listening.close(); + } +} + +suite( + "A source sent to a response that another writer has ended and to a live one", + () => { + for (const [kind, of] of [ + [ + "a Node.js `Readable` that emits its error", + (): IShared => { + const source = new PassThrough(); + return { + source, + fail: (error): void => { + source.destroy(error); + }, + }; + }, + ], + [ + "a Node.js `Readable` destroyed with an error that its `_destroy()` swallows (its `close` with its `errored`)", + (): IShared => { + const source = new PassThrough({ + destroy(_error, callback): void { + callback(null); + }, + }); + return { + source, + fail: (error): void => { + source.destroy(error); + }, + }; + }, + ], + [ + "a stream-like source (`readable-stream` 4) that emits its error", + (): IShared => { + const source = new StreamLike(); + return { + source, + fail: (error): void => { + source.destroy(error); + }, + }; + }, + ], + ] as const) { + for (const first of ["ended", "live"] as const) { + test(`(${kind}) does not log the error that rejects the live send as a late error of the stopped one, whichever listens first (${first}): the ended response is complete`, async () => { + deepStrictEqual(await shared(of(), first), [ + ["complete", "failed"], + { + ended: "resolved", + live: "rejected:The shared source has failed", + }, + [], + ]); + }); + } + } + }, +); diff --git a/packages/binden/test/stream-like-decided.test.ts b/packages/binden/test/stream-like-decided.test.ts new file mode 100644 index 00000000..c5f35501 --- /dev/null +++ b/packages/binden/test/stream-like-decided.test.ts @@ -0,0 +1,128 @@ +import { + Binden, + type Context, + type IStreamLike, + releasedFailure, +} from "../index.js"; +import { capture, listen, send, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +/** Long enough never to expire on a loaded machine: it only guards against a hang */ +const deadline = 10_000; + +/** A stream-like source (not a Node.js `Readable`) */ +type ISource = EventEmitter & IStreamLike; + +/** A `Readable` of `readable-stream` */ +type IReadable = new (options: { + objectMode: boolean; + read(this: { push(chunk: unknown): boolean }): void; +}) => ISource; + +/** The code of an error */ +function codeOf(error: unknown): unknown { + return (error as { code?: unknown }).code; +} + +/** The stream-like sources whose only chunk is an object, which a response cannot send */ +const sources: readonly (readonly [string, () => ISource])[] = [ + ...(["3", "4"] as const).map( + (version) => + [ + `a Readable of readable-stream ${version}`, + (): ISource => { + const { Readable } = require(`readable-stream-v${version}`) as { + Readable: IReadable; + }; + return new Readable({ + objectMode: true, + read(): void { + this.push({ chunk: "not bytes" }); + this.push(null); + }, + }); + }, + ] as const, + ), + [ + "an emitter whose destroy emits its error and its close on the next turn", + (): ISource => { + const source = new EventEmitter(); + let resumed = false; + return Object.assign(source, { + pause(): void { + // Its only chunk comes once + }, + resume(): void { + if (!resumed) { + resumed = true; + queueMicrotask(() => { + source.emit("data", { chunk: "not bytes" }); + }); + } + }, + destroy(error?: Error): void { + setImmediate(() => { + source.emit("error", error); + source.emit("close"); + }); + }, + }); + }, + ], +]; + +suite("A send of a stream-like source whose chunk is not bytes", () => { + for (const [name, create] of sources) { + test(`(${name}) rejects with that error, which is replied, and destroys the source with it: its guard does not log it again`, async () => { + const captured = capture(); + const source = create(); + const emitted = Promise.withResolvers(); + // Once its guard is removed (on the turn after its close) + const unguarded = Promise.withResolvers(); + source.on("error", emitted.resolve); + source.on("close", () => { + setImmediate(() => { + unguarded.resolve(null); + }); + }); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + await context.send(source).then( + () => { + outcome.resolve(null); + }, + (error: unknown) => { + outcome.resolve(error); + throw error; + }, + ); + }, + ); + const listening = await listen(app); + try { + const { status } = await send(listening.port, { ms: deadline }); + const failure = await within(outcome.promise, deadline); + deepStrictEqual( + [status, codeOf(failure)], + [500, "ERR_INVALID_ARG_TYPE"], + ); + // The source is destroyed with it, and emits it + ok((await within(emitted.promise, deadline)) === failure); + await within(unguarded.promise, deadline); + deepStrictEqual( + captured.records.filter(({ msg }) => msg === releasedFailure), + [], + ); + } finally { + await listening.close(); + } + }); + } +}); diff --git a/packages/binden/test/stream-like-decisions.test.ts b/packages/binden/test/stream-like-decisions.test.ts new file mode 100644 index 00000000..a5b10344 --- /dev/null +++ b/packages/binden/test/stream-like-decisions.test.ts @@ -0,0 +1,591 @@ +import { + Binden, + type BindenAbortError, + type Context, + releasedFailure, +} from "../index.js"; +import { HOST, capture, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { + type IStreamLikeMethods, + decided, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { connect } from "node:net"; +import { createRequire } from "node:module"; +import { deepStrictEqual } from "node:assert/strict"; + +const require = createRequire(import.meta.url); + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** A stream of `readable-stream`, or a source of its own */ +interface ISource extends EventEmitter { + pause(): this; + resume(): this; + destroy(error?: Error): unknown; + push?(chunk: unknown): boolean; +} + +/** A `Readable` of `readable-stream` */ +type IReadable = new (options: { read(): void }) => ISource; + +/** A readable of a version of `readable-stream` */ +function readableOf(version: string): ISource { + const { Readable } = require(`readable-stream-v${version}`) as { + Readable: IReadable; + }; + return new Readable({ + read(): void { + // Nothing to read + }, + }); +} + +/** A source of its own whose destroy emits its error, then its `close`, on the next turn */ +class Emitter extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: Error): this { + setImmediate(() => { + if (error) { + this.emit("error", error); + } + this.emit("close"); + }); + return this; + } +} + +/** The sources of the stream-like cases: of `readable-stream` 2, 3 and 4, and of their own */ +const libraries: readonly (readonly [string, () => ISource])[] = [ + ...["2", "3", "4"].map( + (version) => + [ + `readable-stream ${version}`, + (): ISource => readableOf(version), + ] as const, + ), + ["an emitter", (): ISource => new Emitter()], +]; + +/** The messages of the late errors that a logger has logged */ +function lateOf(captured: ReturnType): unknown[] { + return captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => { + const { code, message } = failure as { + code?: unknown; + message?: unknown; + }; + return code ?? message; + }); +} + +/** The status of the first reply, and whether the connection has closed, of `GET /` (once more, `GET /next`) */ +async function exchange( + port: number, + { gone = false, next = false } = {}, +): Promise { + const socket = connect(port, HOST); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + if (gone) { + socket.destroy(); + } + }); + socket.on("error", () => null); + socket.write( + next + ? "GET / HTTP/1.1\r\nHost: a\r\n\r\nGET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n" + : "GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const closed = new Promise((resolve) => { + socket.once("close", resolve); + }); + try { + await within(closed); + } finally { + socket.destroy(); + } + return received.match(/HTTP\/1\.1 \d{3}/gu) ?? []; +} + +/** Runs an application of a middleware (and `/next`) while the client is done */ +async function withApp( + timeout: number, + middleware: (context: Context) => Promise, + client: (port: number, captured: ReturnType) => Promise, +): Promise { + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + timeout, + }) + .use("/next", (context: Context) => context.send("next")) + .use(middleware); + const listening = await listen(app); + try { + await client(listening.port, captured); + } finally { + await listening.close(); + } +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +suite( + "A stream-like source destroyed with an error before the send (its `errored`)", + () => { + test("rejects with that error, which is not logged, and is not destroyed again", async () => { + const source = readableOf("4"); + const destroys: unknown[] = []; + const destroy = source.destroy.bind(source); + const rejected: unknown[] = []; + await withApp( + 0, + async (context: Context) => { + destroy(new Error("Failed before the send")); + Object.assign(source, { + destroy(error?: Error): void { + destroys.push(error); + destroy(error); + }, + }); + await context.send(source).catch((error: unknown) => { + rejected.push((error as Error).message); + throw error; + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port), ["HTTP/1.1 500"]); + await pollUntil(() => source.listenerCount("error") === 0); + await ioTurn(); + deepStrictEqual( + [rejected, destroys, lateOf(captured)], + [["Failed before the send"], [], []], + ); + }, + ); + }); + + test("once the response has ended (by a getter of its `errored`): resolves, and that error is logged once, also once the source emits it", async () => { + const failure = new Error("Failed before the send"); + const source = Object.assign(new Emitter(), { destroyed: true }); + const outcomes: string[] = []; + await withApp( + 0, + async (context: Context) => { + Object.defineProperty(source, "errored", { + get(): Error { + context.response.end("ended"); + return failure; + }, + }); + outcomes.push( + await context.send(source).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ), + ); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port, { next: true }), [ + "HTTP/1.1 200", + "HTTP/1.1 200", + ]); + await pollUntil(() => outcomes.length === 1); + // Its guard (it never closes) + source.emit("error", failure); + await ioTurn(); + deepStrictEqual( + [outcomes, lateOf(captured)], + [["resolved"], ["Failed before the send"]], + ); + }, + ); + }); + }, +); + +suite("A stream-like source cancelled by the send", () => { + for (const [library, create] of libraries) { + for (const gone of [false, true]) { + test(`(${library}) ${gone ? "once the client has gone away" : "at the deadline"}: destroyed with the reason, which its guard does not log once it emits it`, async () => { + const source = create(); + const emitted: unknown[] = []; + // The application listens: its guard does not report less + source.on("error", (error: unknown) => emitted.push(error)); + const rejected: unknown[] = []; + await withApp( + gone ? 0 : 50, + async (context: Context) => { + if (gone) { + setImmediate(() => { + if (typeof source.push === "function") { + source.push("first"); + } else { + source.emit("data", "first"); + } + }); + } + await context.send(source).catch((error: unknown) => { + rejected.push(error); + throw error; + }); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port, { gone }), [ + gone ? "HTTP/1.1 200" : "HTTP/1.1 503", + ]); + await pollUntil(() => emitted.length > 0); + await ioTurn(); + const [reason] = emitted; + deepStrictEqual( + [ + gone + ? (reason as BindenAbortError).reason + : reason === rejected[0], + lateOf(captured), + ], + [gone ? "client_closed" : true, []], + ); + }, + ); + }); + } + } +}); + +suite( + "A stream-like source that cannot be read once the response has ended (by code of the source)", + () => { + for (const how of ["errored", "on"] as const) { + for (const [library, create] of [ + ["an emitter", (): ISource => new Emitter()], + [ + "readable-stream 4", + (): ISource => { + const { Readable } = require("readable-stream-v4") as { + Readable: new (options: { + read(): void; + autoDestroy: boolean; + }) => ISource; + }; + return new Readable({ + autoDestroy: false, + read(): void { + // Nothing to read + }, + }); + }, + ], + ] as const) { + test(`(${library}, ${how === "errored" ? "a getter of its `errored`" : "its own on() that then throws"}): resolves, and its error is logged once`, async () => { + const failure = new Error("Source failed"); + const outcomes: string[] = []; + await withApp( + 0, + async (context: Context) => { + const source = create(); + // The application listens + source.on("error", () => null); + if (how === "errored") { + Object.defineProperty(source, "errored", { + get(): Error { + context.response.end("ended"); + return failure; + }, + }); + } else { + const on = source.on.bind(source); + let added = 0; + Object.assign(source, { + on(event: string, listener: () => void): ISource { + added += 1; + if (added === 1) { + context.response.end("ended"); + throw failure; + } + return on(event, listener); + }, + }); + } + outcomes.push( + await context.send(source).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ), + ); + }, + async (port, captured) => { + deepStrictEqual(await exchange(port, { next: true }), [ + "HTTP/1.1 200", + "HTTP/1.1 200", + ]); + await pollUntil(() => outcomes.length === 1); + await ioTurn(); + deepStrictEqual( + [outcomes, lateOf(captured)], + [["resolved"], ["Source failed"]], + ); + }, + ); + }); + } + } + }, +); + +suite( + "A stream-like source whose destroy emits an error at once, while its send decides it", + () => { + /** + * A source whose `on()` throws `thrown` for `end` (once it has run + * `before()`), and whose destroy emits `emitted` at once + */ + function deciding( + thrown: Error, + emitted: readonly Error[], + before = (): null => null, + ): Emitter { + const source: Emitter = Object.assign(new Emitter(), { + on(event: string, listener: () => void): Emitter { + if (event === "end") { + before(); + throw thrown; + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + destroy(): void { + for (const error of emitted) { + source.emit("error", error); + } + }, + }); + return source; + } + + test("that error is never reported (the send rejects with it), but another one is", async () => { + const reported: unknown[] = []; + const thrown = new Error("Thrown"); + const source = deciding(thrown, [thrown, new Error("Another")]); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + (error) => reported.push((error as Error).message), + ); + const failure = await within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); + // Reported on a later turn + await ioTurn(); + deepStrictEqual([failure === thrown, reported], [true, ["Another"]]); + }); + + test("sent: the send rejects with it, which is not logged; once the response has ended (by its on()), the send resolves, and it is logged once", async () => { + const outcomes: string[] = []; + await withApp( + 0, + async (context: Context) => { + const thrown = new Error("Thrown"); + const ends = context.url.pathname === "/ended"; + const source = deciding(thrown, [thrown], () => { + if (ends) { + context.response.end("ended"); + } + return null; + }); + outcomes.push( + await context.send(source).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ), + ); + }, + async (port, captured) => { + const statuses = []; + for (const path of ["/", "/ended"]) { + const socket = connect(port, HOST); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + socket.write( + `GET ${path} HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n`, + ); + await within( + new Promise((resolve) => { + socket.once("close", resolve); + }), + ); + statuses.push(/^HTTP\/1\.1 \d{3}/u.exec(received)?.[0]); + } + await pollUntil(() => outcomes.length === 2); + await ioTurn(); + // The rejection caught: nothing replied (404) + deepStrictEqual( + [statuses, outcomes, lateOf(captured)], + [ + ["HTTP/1.1 404", "HTTP/1.1 200"], + ["Thrown", "resolved"], + ["Thrown"], + ], + ); + }, + ); + }); + }, +); + +suite( + "Code of a stream-like source that throws while send() adds its listeners", + () => { + test("the same error for the listener of `close` of its guard: the readable fails with it, which neither its refusal nor its destroy logs", async () => { + const reported: unknown[] = []; + const thrown = new Error("No listener of close"); + const source = new Emitter(); + source.on("newListener", (event: string) => { + if (event === "close") { + throw thrown; + } + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + (error) => reported.push(error), + () => null, + { + released: (error) => reported.push(error), + removed: (error) => reported.push(error), + }, + ); + // As the send does once it rejects (see `BindenResponse.#unreadable()`) + decided(readable.errored); + const failure = await within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); + // Its destroy emits it on the next turn + await ioTurn(); + deepStrictEqual([failure === thrown, reported], [true, []]); + }); + + for (const version of ["2", "3", "4"]) { + for (const [name, value] of [ + ["null", null], + ["0", 0], + ] as const) { + test(`(readable-stream ${version}) a falsy value (${name}): destroyed with a premature close, which is not logged once it emits it`, async () => { + const reported: unknown[] = []; + const emitted: unknown[] = []; + const source = readableOf(version); + source.on("error", (error: unknown) => emitted.push(error)); + source.on("newListener", (event: string) => { + if (event === "data") { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- code of a source may throw anything + throw value; + } + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => + Object.assign(new Error("Premature close"), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }), + (error) => reported.push(error), + ); + // Destroyed without an error: no `error` event + await within( + new Promise((resolve) => { + readable.once("close", resolve); + }), + ); + await pollUntil(() => emitted.length > 0); + await ioTurn(); + deepStrictEqual( + [(emitted[0] as { code?: unknown }).code, reported], + ["ERR_STREAM_PREMATURE_CLOSE", []], + ); + }); + } + } + }, +); + +suite( + "The decision of a send of a stream-like source that cannot be read", + () => { + test("of a value that Node.js cannot take as an error, wrapped (its cause): the Error that its destroy emits at once is not reported", async () => { + const reported: unknown[] = []; + const thrown = new Error("Unstackable"); + Object.defineProperty(thrown, "stack", { + get(): never { + throw new Error("Its stack cannot be read"); + }, + }); + const source: EventEmitter = Object.assign(new Emitter(), { + on(event: string, listener: () => void): EventEmitter { + if (event === "end") { + throw thrown; + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + destroy(error: Error): void { + source.emit("error", error); + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + (error) => reported.push(error), + ); + // As the send does once it rejects (see `BindenResponse.#unreadable()`) + decided(readable.errored); + const failure = await within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); + await ioTurn(); + deepStrictEqual([failure.cause === thrown, reported], [true, []]); + }); + + test("is not taken for an `undefined` that a source emits later as its error: its guard reports it", async () => { + const reported: unknown[] = []; + const source = Object.assign(new Emitter(), { readableEnded: true }); + readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + (error) => reported.push(error), + ); + source.emit("error", none); + await ioTurn(); + deepStrictEqual(reported, [none]); + }); + }, +); diff --git a/packages/binden/test/stream-like-left-listeners.test.ts b/packages/binden/test/stream-like-left-listeners.test.ts new file mode 100644 index 00000000..e4b7ab59 --- /dev/null +++ b/packages/binden/test/stream-like-left-listeners.test.ts @@ -0,0 +1,87 @@ +import { EventEmitter, once } from "node:events"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { ioTurn, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, with `--expose-gc` + * and arguments + */ +function fixture(name: string, ...args: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--expose-gc", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ...args, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite( + "The listeners of a send that a removal that throws leaves on a stream-like source (Codex R23, 8b)", + () => { + test("of its data, its end or its close, on sources that the application keeps: the responses are collected", () => { + for (const [event, left] of [ + ["data", 200], + ["end", 200], + // And the one of its guard + ["close", 400], + ] as const) { + deepStrictEqual( + fixture("left-listeners.ts", event), + { status: 0, stdout: `collected ${left}\n`, stderr: "" }, + event, + ); + } + }); + + test("of its data: one that the source emits once it has ended changes nothing (the source is not paused)", async () => { + let pauses = 0; + const source = Object.assign(new EventEmitter(), { + pause(): void { + pauses += 1; + }, + resume(): void { + // Flows + }, + removeListener(event: string, listener: () => void): void { + if (event === "data") { + throw new Error("The listener cannot be removed"); + } + EventEmitter.prototype.removeListener.call(source, event, listener); + }, + }); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + ); + const closed = once(readable, "close"); + readable.resume(); + source.emit("data", Buffer.from("ok")); + source.emit("end"); + await within(closed); + // Left on the source + source.emit("data", Buffer.from("late")); + await ioTurn(); + deepStrictEqual( + [source.listenerCount("data"), pauses, readable.errored], + [1, 0, null], + ); + }); + }, +); diff --git a/packages/binden/test/stream-like-logs.test.ts b/packages/binden/test/stream-like-logs.test.ts new file mode 100644 index 00000000..8808b827 --- /dev/null +++ b/packages/binden/test/stream-like-logs.test.ts @@ -0,0 +1,264 @@ +import { + type ISourceReport, + type IStreamLikeLogs, + type IStreamLikeMethods, + decided, + emitterMethods, + guard, + hear, + readStreamLike, + releaseStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; +import { ioTurn } from "./helpers.js"; + +/** A stream-like source of its own: an emitter with `pause()` and `resume()` */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } +} + +/** What has been reported as a late error, and logged by each log of a release */ +interface IRecorded { + readonly report: ISourceReport; + readonly logs: IStreamLikeLogs; + /** The messages, by where they have gone */ + readonly seen: Record<"late" | "released" | "removed", unknown[]>; +} + +/** Records the late errors and the logs of a release (see `IStreamLikeLogs`) */ +function record(): IRecorded { + const seen = { + late: [] as unknown[], + released: [] as unknown[], + removed: [] as unknown[], + }; + /** The message of an error, any other value as it is */ + function message(error: unknown): unknown { + return error instanceof Error ? error.message : error; + } + return { + report: (error) => seen.late.push(message(error)), + logs: { + released: (error) => seen.released.push(message(error)), + removed: (error) => seen.removed.push(message(error)), + }, + seen, + }; +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +/** A source whose listeners of `event` are refused (by a listener of its `newListener`) with `error`, after `after` of them */ +function refusing(event: string, error: unknown, after = 0): Source { + const source = new Source(); + let added = 0; + source.on("newListener", (name: string) => { + if (name === event) { + added += 1; + if (added > after) { + throw error; + } + } + }); + return source; +} + +suite("guard() of a source whose code refuses it", () => { + test("never gives an error that has decided a send to `refused`: of its listener of `error`, of `close`, or of the removal after a refusal", () => { + const refused: unknown[] = []; + const late: unknown[] = []; + const add = (error: unknown): void => { + refused.push((error as Error).message); + }; + const report = (error: unknown): void => { + late.push(error); + }; + const decision = new Error("Decided"); + decided(decision); + // Its listener of `error`: not guarded, whatever it has thrown + const errors = refusing("error", decision); + const other = refusing("error", new Error("Not decided")); + // Its listener of `close`: guarded, as without the refusal + const closes = refusing("close", decision); + // Its `on()` adds the listener, then throws; and so does the removal + const removing = Object.assign(new Source(), { + on(): never { + throw new Error("Refused"); + }, + removeListener(): never { + throw decision; + }, + }); + deepStrictEqual( + [ + guard(errors, emitterMethods, report, false, add), + guard(other, emitterMethods, report, false, add), + guard(closes, emitterMethods, report, false, add), + guard(removing, methodsOf(removing), report, false, add), + ], + [false, false, true, false], + ); + deepStrictEqual([refused, late], [["Not decided", "Refused"], []]); + }); +}); + +suite("hear() of a source", () => { + test("while a send hears it (however many), its guard does not report its error: once none does, it does", async () => { + const late: unknown[] = []; + const source = new Source(); + guard(source, emitterMethods, (error) => late.push(error)); + hear(source, 1); + hear(source, 1); + source.emit("error", "heard by two"); + hear(source, -1); + source.emit("error", "heard by one"); + hear(source, -1); + source.emit("error", "heard by none"); + // Reported on a later turn + await ioTurn(); + deepStrictEqual(late, ["heard by none"]); + }); +}); + +suite( + "readStreamLike() of a source whose code throws once it is no longer read", + () => { + test("logs a refusal of its guard by `released`, never as a late error", async () => { + const recorded = record(); + // The listener of the send is added, the one of its guard refused + const source = refusing("error", new Error("Guard refused"), 1); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + () => null, + recorded.logs, + ); + readable.on("error", () => null).destroy(new Error("Cancelled")); + await ioTurn(); + deepStrictEqual(recorded.seen, { + late: [], + released: ["Guard refused"], + removed: [], + }); + }); + + test("logs a pause() that throws, of a source without destroy() once cancelled, by `released`", async () => { + const recorded = record(); + const source = Object.assign(new Source(), { + pause(): never { + throw new Error("Pause failed"); + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + () => null, + recorded.logs, + ); + readable.on("error", () => null).destroy(new Error("Cancelled")); + await ioTurn(); + deepStrictEqual(recorded.seen, { + late: [], + released: ["Pause failed"], + removed: [], + }); + }); + + test("logs a removal that throws, at once and on the next turn, by `removed`", async () => { + const recorded = record(); + const source = Object.assign(new Source(), { + removeListener(event: string): never { + throw new Error(`Cannot remove ${event}`); + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + () => null, + recorded.logs, + ); + readable.resume(); + source.emit("end"); + await ioTurn(); + deepStrictEqual(recorded.seen, { + late: [], + released: [], + removed: [ + "Cannot remove data", + "Cannot remove end", + "Cannot remove close", + "Cannot remove error", + ], + }); + }); + + test("without logs, reports them as late errors (the default)", async () => { + const recorded = record(); + const source = Object.assign(refusing("error", new Error("Refused"), 1), { + removeListener(): never { + throw new Error("Cannot remove"); + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + ); + readable.on("error", () => null).destroy(new Error("Cancelled")); + await ioTurn(); + // The removal of the refused listener of its guard, then of the + // listeners of the send (but the one of `error`, kept) + deepStrictEqual(recorded.seen.late, [ + "Refused", + "Cannot remove", + "Cannot remove", + "Cannot remove", + "Cannot remove", + ]); + }); + }, +); + +suite("releaseStreamLike() of a source whose code throws", () => { + test("logs a refusal of its guard by `released`, never as a late error (without logs, as one)", () => { + const recorded = record(); + for (const logs of [recorded.logs, null]) { + const source = refusing("close", new Error("Close refused")); + if (logs === null) { + releaseStreamLike(source, methodsOf(source), recorded.report); + } else { + releaseStreamLike( + source, + methodsOf(source), + recorded.report, + () => null, + logs, + ); + } + } + deepStrictEqual(recorded.seen, { + late: ["Close refused"], + released: ["Close refused"], + removed: [], + }); + }); +}); diff --git a/packages/binden/test/stream-like-release-failures.test.ts b/packages/binden/test/stream-like-release-failures.test.ts new file mode 100644 index 00000000..78c078f0 --- /dev/null +++ b/packages/binden/test/stream-like-release-failures.test.ts @@ -0,0 +1,345 @@ +import { Binden, BindenResponse, type Context } from "../index.js"; +import { HOST, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { + type ISourceReport, + type IStreamLikeLogs, + type IStreamLikeMethods, + readStreamLike, + releaseStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { IncomingMessage, request } from "node:http"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { Socket } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** A stream-like source of its own */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } +} + +/** A source whose `destroy()` throws `thrown` */ +function throwing(thrown: unknown): Source { + return Object.assign(new Source(), { + destroy(): never { + throw thrown; + }, + }); +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +/** What a release has given its report (`late`) and its logs */ +interface IRecorded { + readonly late: unknown[]; + readonly released: unknown[]; + readonly report: ISourceReport; + readonly logs: IStreamLikeLogs; +} + +/** The message of an error, any other value as it is */ +function messageOf(error: unknown): unknown { + return error instanceof Error ? error.message : error; +} + +/** Records the late errors and the logs of a release */ +function record(): IRecorded { + const late: unknown[] = []; + const released: unknown[] = []; + return { + late, + released, + report: (error): void => { + late.push(messageOf(error)); + }, + logs: { + released: (error): void => { + released.push(messageOf(error)); + }, + removed: (error): void => { + released.push(`removal: ${String(messageOf(error))}`); + }, + }, + }; +} + +/** The error that a readable is destroyed with */ +function failureOf(readable: NodeJS.ReadableStream): Promise { + return within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); +} + +/** An object thrown by code of a source, whose `stack` getter throws (as Node.js reads it once destroyed with it) */ +function unstackable(): Error { + const error = new Error("Unstackable"); + Object.defineProperty(error, "stack", { + get(): never { + throw new Error("Its stack cannot be read"); + }, + }); + return error; +} + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, without a handler + * of the process: a crash (e.g. an unhandled `error` event) is its exit code + * and output, not a failure of the test runner + */ +function fixture(name: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite("A stream-like source whose destroy() throws", () => { + test("once its resume() has failed the send: the readable fails with that error, and the throw of its destroy is logged", async () => { + const recorded = record(); + const source = Object.assign(throwing(new Error("Release failed")), { + resume(): never { + throw new Error("Resume failed"); + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + () => null, + recorded.logs, + ); + readable.resume(); + deepStrictEqual(messageOf(await failureOf(readable)), "Resume failed"); + await ioTurn(); + deepStrictEqual( + [recorded.released, recorded.late], + [["Release failed"], []], + ); + }); + + test("once cancelled, without its guard (the listener of the send kept): the readable fails with the reason, and the throw is logged", async () => { + const recorded = record(); + const source = throwing(null); + let errors = 0; + source.on("newListener", (event: string) => { + if (event === "error") { + errors += 1; + if (errors === 2) { + throw new Error("Guard refused"); + } + } + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + () => null, + recorded.logs, + ); + const failure = failureOf(readable); + readable.destroy(new Error("Cancelled")); + deepStrictEqual(messageOf(await failure), "Cancelled"); + deepStrictEqual( + [recorded.released, recorded.late], + [["Guard refused", null], []], + ); + }); + + test("released (to HEAD): what it throws is logged, and the release goes on; so does a pause() that throws, without destroy()", () => { + const recorded = record(); + const pausing = Object.assign(new Source(), { + pause(): never { + throw new Error("Pause failed"); + }, + }); + for (const source of [ + throwing(new Error("Release failed")), + throwing(null), + pausing, + ]) { + releaseStreamLike( + source, + methodsOf(source), + recorded.report, + () => null, + recorded.logs, + ); + } + deepStrictEqual( + [recorded.released, recorded.late], + [["Release failed", null, "Pause failed"], []], + ); + }); + + test("not sent (to HEAD, or for 103): HEAD gets its reply and the send resolves, the send for 103 rejects with its TypeError", async () => { + const outcomes: unknown[] = []; + const app = new Binden().use(async (context: Context) => { + if (context.url.pathname === "/103") { + context.status(103); + } + outcomes.push( + await context.send(throwing(null)).then( + () => "resolved", + (error: unknown) => (error as Error).name, + ), + ); + }); + const listening = await listen(app); + try { + const statuses: number[] = []; + for (const [method, path] of [ + ["HEAD", "/"], + ["GET", "/103"], + ] as const) { + const response = await within( + new Promise((resolve, reject) => { + request( + { agent: false, host: HOST, port: listening.port, method, path }, + resolve, + ) + .once("error", reject) + .end(); + }), + ); + response.resume(); + statuses.push(response.statusCode ?? 0); + } + await pollUntil(() => outcomes.length === 2); + // The rejection for 103 caught: nothing replied + deepStrictEqual( + [statuses, outcomes], + [ + [200, 404], + ["resolved", "TypeError"], + ], + ); + } finally { + await listening.close(); + } + }); + + test("released for 103 without the framework: the send rejects with its TypeError, not with what its destroy() throws", async () => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + const outcome = await response + .status(103) + .send(throwing(new Error("Release failed"))) + .then( + () => "resolved", + (error: unknown) => (error as Error).name, + ); + deepStrictEqual(outcome, "TypeError"); + }); +}); + +suite( + "Code of a stream-like source that throws a value that Node.js cannot take as an error (its stack getter throws)", + () => { + test("while its listeners are added: the readable fails with an Error that carries it as its cause, and the listeners of the send are released", async () => { + const recorded = record(); + const thrown = unstackable(); + const source = Object.assign(new Source(), { + destroy: (): null => null, + on(event: string, listener: () => void): Source { + if (event === "end") { + throw thrown; + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + () => null, + recorded.logs, + ); + const failure = (await failureOf(readable)) as Error; + await ioTurn(); + // Its guard stays (its state cannot tell that it has closed) + deepStrictEqual( + [ + failure.message, + failure.cause === thrown, + ["data", "end", "error", "close"].map((event) => + source.listenerCount(event), + ), + ], + ["A stream has failed", true, [0, 0, 1, 1]], + ); + source.emit("error", new Error("Later")); + // Reported on a later turn + await ioTurn(); + deepStrictEqual([recorded.late, recorded.released], [["Later"], []]); + }); + + test("in its pause() once the readable is full, or as its error: the readable fails with an Error that carries it as its cause, and nothing is thrown into its events", async () => { + for (const how of ["pause", "error"] as const) { + const recorded = record(); + const thrown = unstackable(); + const source = Object.assign(new Source(), { + destroy: (): null => null, + pause(): never { + throw thrown; + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + recorded.report, + () => null, + recorded.logs, + ); + const failure = failureOf(readable); + if (how === "pause") { + // More than the readable takes: it pauses the source + source.emit("data", Buffer.alloc(1 << 20)); + } else { + source.emit("error", thrown); + } + const error = (await failure) as Error; + await ioTurn(); + deepStrictEqual( + [error.message, error.cause === thrown, recorded.late], + ["A stream has failed", true, []], + how, + ); + } + }); + + test("sent, once its listeners are added or in its pause(): the send rejects, and a later error of the source does not crash the process", () => { + deepStrictEqual(fixture("unstackable-sources.ts"), { + status: 0, + stdout: + "/setup 500 rejected:original threw= late=Later\n" + + "/pause 200 rejected:original threw= late=Later\n", + stderr: "", + }); + }); + }, +); diff --git a/packages/binden/test/stream-like-release-logs.test.ts b/packages/binden/test/stream-like-release-logs.test.ts new file mode 100644 index 00000000..306e6ab1 --- /dev/null +++ b/packages/binden/test/stream-like-release-logs.test.ts @@ -0,0 +1,141 @@ +import { Binden, type Context } from "../index.js"; +import { capture, ioTurn, listen, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; + +/** A stream-like source: an emitter with `pause()` and `resume()` */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } +} + +/** + * Sends a stream-like source (made for the context, with `timeout` ms of + * deadline, `0` for none) to a method: the status that the client gets, and + * each record of a release of a stream (`: `) + */ +async function released( + source: (context: Context) => Source, + { method = "GET", timeout = 0 } = {}, +): Promise<[number, string[]]> { + const captured = capture(); + const listening = await listen( + new Binden({ + logger: captured.logger, + log_error_details: true, + timeout, + }).use(async (context: Context) => { + await context.send(source(context)); + }), + ); + try { + const { status } = await send(listening.port, { method }); + // Any later record (e.g. of the deferred removal of a listener) + await ioTurn(); + const records = captured.records + .filter(({ msg }) => + [ + "The release of a stream has failed", + "The removal of a listener of a stream has failed", + "A released stream has failed", + ].includes(String(msg)), + ) + .map( + ({ msg, failure }) => + `${String(msg)}: ${String((failure as { message?: unknown }).message)}`, + ); + return [status, records]; + } finally { + await listening.close(); + } +} + +suite( + "Code of a stream-like source that throws while send() releases it", + () => { + test("a refusal of its guard is logged as the release of a Node.js `Readable` that has failed, not as a late error", async () => { + for (const method of ["HEAD", "GET"]) { + const [status, records] = await released( + () => { + const source = new Source(); + source.on("newListener", (event: string) => { + if (event === "error") { + throw new Error(`No listener of error (${method})`); + } + }); + return source; + }, + { method }, + ); + // To `HEAD`, released without being read; to `GET`, the listener + // of its send is refused as well (the send rejects with it) + deepStrictEqual( + [status, records], + [ + method === "HEAD" ? 200 : 500, + [ + `The release of a stream has failed: No listener of error (${method})`, + ], + ], + ); + } + }); + + test("a removal of a listener of the send that throws is logged as for a Node.js `Readable`, not as a late error", async () => { + const [status, records] = await released(() => { + const source = new Source(); + Object.assign(source, { + removeListener(event: string, listener: () => void): Source { + if (event === "end") { + throw new Error("No removal of end"); + } + EventEmitter.prototype.removeListener.call(source, event, listener); + return source; + }, + }); + setImmediate(() => { + source.emit("data", "ok"); + source.emit("end"); + }); + return source; + }); + deepStrictEqual( + [status, records], + [ + 200, + [ + "The removal of a listener of a stream has failed: No removal of end", + ], + ], + ); + }); + + test("a pause() that throws once it is cancelled (without destroy()) is logged as the release of a Node.js `Readable` that has failed, not as a late error", async () => { + const [status, records] = await released( + () => { + let pauses = 0; + const source = new Source(); + Object.assign(source, { + pause(): never { + pauses += 1; + throw new Error(`Its pause #${String(pauses)} has failed`); + }, + }); + return source; + }, + { timeout: 50 }, + ); + deepStrictEqual( + [status, records], + [503, ["The release of a stream has failed: Its pause #1 has failed"]], + ); + }); + }, +); diff --git a/packages/binden/test/stream-like-safe-stacks.test.ts b/packages/binden/test/stream-like-safe-stacks.test.ts new file mode 100644 index 00000000..b8c791b4 --- /dev/null +++ b/packages/binden/test/stream-like-safe-stacks.test.ts @@ -0,0 +1,391 @@ +import { Binden, type Context } from "../index.js"; +import { EventEmitter, once } from "node:events"; +import { HOST, capture, ioTurn, listen, within } from "./helpers.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, + uncarried, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { connect } from "node:net"; +import { createRequire } from "node:module"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const require = createRequire(import.meta.url); + +/** An error whose `stack` getter returns a string on its first read, and throws on the next ones */ +interface IOnce { + readonly error: Error; + /** How many times its `stack` has been read */ + reads(): number; +} + +/** An error whose `stack` getter throws from its second read (see `IOnce`) */ +function readOnce(): IOnce { + const error = new Error("Read once"); + let reads = 0; + Object.defineProperty(error, "stack", { + get(): string { + reads += 1; + if (reads > 1) { + throw new Error("Its stack cannot be read again"); + } + return "Error: Read once"; + }, + }); + return { error, reads: () => reads }; +} + +/** A stream-like source of its own, which records the values that it is destroyed with */ +class Source extends EventEmitter { + public readonly destroys: unknown[] = []; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: Error): this { + this.destroys.push(error); + return this; + } +} + +/** The error that a readable is destroyed with */ +function failureOf(readable: NodeJS.ReadableStream): Promise { + return within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); +} + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, with arguments, + * without a handler of the process: a crash (e.g. a throw into an event of a + * source) is its exit code and output, not a failure of the test runner + */ +function fixture(name: string, ...args: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ...args, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +/** A chunk that the response does not take at once (its `write()` returns `false`) */ +const big = Buffer.alloc(1 << 20, 0x61); + +suite( + "A stream-like source that fails with a value whose stack Node.js cannot read safely (R23-1)", + () => { + test("emitted, or thrown by its pause() or by its on(), with a stack getter that throws once read again: the readable is destroyed once, with a carrier, and the source with that carrier", async () => { + for (const how of ["emit", "pause", "on"] as const) { + const value = readOnce(); + const source = Object.assign(new Source(), { + on(event: string, listener: () => void): Source { + if (how === "on" && event === "end") { + throw value.error; + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + pause(): Source { + if (how === "pause") { + throw value.error; + } + return source; + }, + }); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + ); + const failure = failureOf(readable); + if (how === "emit") { + source.emit("error", value.error); + } else if (how === "pause") { + // More than the readable takes: it pauses the source + source.emit("data", big); + } + const carrier = (await failure) as Error; + await ioTurn(); + deepStrictEqual( + [ + carrier.message, + carrier.stack?.startsWith("Error: A stream has failed"), + carrier.cause === value.error, + uncarried(carrier, readable) === value.error, + readable.errored === carrier, + readable.closed, + // Once it has emitted it, it is destroyed without it + source.destroys.map((error) => { + const kind = error === carrier ? "carrier" : typeof error; + return kind; + }), + value.reads(), + ], + [ + "A stream has failed", + true, + true, + true, + true, + true, + how === "emit" ? ["undefined"] : ["carrier"], + 0, + ], + how, + ); + } + }); + + test("thrown by its pause(), and again by its code while its guard is installed (a listener of its newListener): not logged, since the send rejects with it", async () => { + const value = readOnce(); + const released: unknown[] = []; + const source = Object.assign(new Source(), { + pause(): never { + source.on("newListener", () => { + throw value.error; + }); + throw value.error; + }, + }); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + () => null, + { + released: (error): void => { + released.push(error); + }, + removed: (error): void => { + released.push(error); + }, + }, + ); + const failure = failureOf(readable); + source.emit("data", big); + const carrier = (await failure) as Error; + await ioTurn(); + deepStrictEqual( + [uncarried(carrier, readable) === value.error, released, value.reads()], + [true, [], 0], + ); + }); + + test("an error of binden whose stack a prepareStackTrace of the application fails to format (a chunk that is not bytes): a carrier whose stack is its header, for the readable and the source", async () => { + const source = new Source(); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + ); + const failure = failureOf(readable); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (): never => { + throw new Error("The stack cannot be formatted"); + }); + try { + source.emit("data", null); + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + const carrier = (await failure) as Error; + const carried = uncarried(carrier, readable) as Error & { + code?: unknown; + }; + deepStrictEqual( + [ + carrier.stack, + carrier.cause === carried, + carried.code, + source.destroys.length, + source.destroys[0] === carrier, + ], + ["Error: A stream has failed", true, "ERR_INVALID_ARG_TYPE", 1, true], + ); + }); + + test("sent, emitted or thrown by its pause(), with or without a deadline: the send rejects with the value itself at once, and the request pipelined after one that has sent nothing is answered", async () => { + for (const how of ["emit", "pause"] as const) { + for (const timeout of [0, 20_000]) { + const value = readOnce(); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger, timeout }).use( + async (context: Context) => { + if (context.url.pathname === "/second") { + await context.send("second"); + return; + } + const source = Object.assign(new Source(), { + pause(): Source { + if (how === "pause") { + throw value.error; + } + return source; + }, + }); + setImmediate(() => { + if (how === "emit") { + source.emit("error", value.error); + } else { + // Written at once, then kept by the readable, which pauses + // the source + source.emit("data", big); + source.emit("data", big); + } + }); + await context.send(source).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve(error); + }, + ); + throw new Error("Failed"); + }, + ); + const listening = await listen(app); + try { + const socket = connect(listening.port, HOST); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + socket.on("error", () => null); + await once(socket, "connect"); + socket.write( + "GET / HTTP/1.1\r\nHost: a\r\n\r\nGET /second HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + // At once, long before the deadline + const rejected = await within(outcome.promise); + await within(once(socket, "close")); + deepStrictEqual( + [ + rejected === value.error, + received.match(/^HTTP\/1\.1 \d{3}/gmu), + value.reads(), + ], + [ + true, + // Once its headers are sent, the response is closed + how === "emit" + ? ["HTTP/1.1 500", "HTTP/1.1 200"] + : ["HTTP/1.1 200"], + 0, + ], + `${how} ${timeout}`, + ); + } finally { + await listening.close(); + } + } + } + }); + + test("of `readable-stream` 4 whose pause() throws it: the send rejects with it, and the source is destroyed by one call of its destroy()", async () => { + const value = readOnce(); + const { PassThrough } = require("readable-stream-v4") as { + PassThrough: new () => EventEmitter & { + readonly destroyed: boolean; + pause(): unknown; + resume(): unknown; + destroy(error?: Error): unknown; + write(chunk: Buffer): boolean; + }; + }; + const source = new PassThrough(); + const destroys: unknown[] = []; + const destroy = source.destroy.bind(source); + Object.assign(source, { + pause(): never { + throw value.error; + }, + destroy(error?: Error): unknown { + destroys.push(error); + return destroy(error); + }, + }); + const outcome = Promise.withResolvers(); + const app = new Binden({ logger: capture().logger }).use( + async (context: Context) => { + setImmediate(() => { + for (let chunk = 0; chunk < 8 && !source.destroyed; chunk += 1) { + source.write(big); + } + }); + await context.send(source).then( + () => { + outcome.resolve("resolved"); + }, + (error: unknown) => { + outcome.resolve(error); + }, + ); + }, + ); + const listening = await listen(app); + try { + const socket = connect(listening.port, HOST); + socket.resume().on("error", () => null); + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"); + const rejected = await within(outcome.promise); + await within(once(socket, "close")); + deepStrictEqual( + [ + rejected === value.error, + source.destroyed, + destroys.length, + (destroys[0] as Error).cause === value.error, + value.reads(), + ], + [true, true, 1, true, 0], + ); + } finally { + await listening.close(); + } + }); + + test("sent, without a handler of the process, also while a prepareStackTrace of the application throws: the send rejects with the value, and nothing is thrown into the events of the source", () => { + deepStrictEqual( + fixture( + "unstackable-sources.ts", + "/emit-once", + "/pause-once", + "/pst-emit", + "/pst-chunk", + ), + { + status: 0, + stdout: + "/emit-once 500 rejected:original threw= late=Later\n" + + "/pause-once 200 rejected:original threw= late=Later\n" + + "/pst-emit 500 rejected:original threw= late=Later\n" + + "/pst-chunk 500 rejected:The chunk of a stream-like source must be a string or bytes (received null) threw= late=Later\n", + stderr: "", + }, + ); + }); + }, +); diff --git a/packages/binden/test/stream-like-sent-failures.test.ts b/packages/binden/test/stream-like-sent-failures.test.ts new file mode 100644 index 00000000..3825836b --- /dev/null +++ b/packages/binden/test/stream-like-sent-failures.test.ts @@ -0,0 +1,295 @@ +import { Binden, type Context } from "../index.js"; +import { HOST, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { connect } from "node:net"; +import { createRequire } from "node:module"; +import { deepStrictEqual } from "node:assert/strict"; + +const require = createRequire(import.meta.url); + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** A stream-like source of its own without `destroy()`, whose calls are recorded */ +class Pausable extends EventEmitter { + public readonly calls: unknown[][] = []; + + public pause(): this { + this.calls.push(["pause"]); + return this; + } + + public resume(): this { + return this; + } +} + +/** A stream-like source of its own whose calls are recorded */ +class Recorded extends Pausable { + public destroy(...args: unknown[]): this { + this.calls.push(["destroy", ...args]); + return this; + } +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +/** The readable of a stream-like source, flowing, and how it ends: `ended`, or its error */ +function read( + source: object, + logs: unknown[] = [], +): [NodeJS.ReadableStream, Promise] { + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + (error) => logs.push(error), + () => null, + { + released: (error) => logs.push(error), + removed: (error) => logs.push(error), + }, + ); + const outcome = within( + new Promise((resolve) => { + readable.once("end", () => { + resolve("ended"); + }); + readable.once("error", resolve); + }), + ); + readable.resume(); + return [readable, outcome]; +} + +/** The code of an error */ +function codeOf(error: unknown): unknown { + return (error as { code?: unknown }).code; +} + +suite( + "A data event of a stream-like source that is neither a string nor bytes", + () => { + for (const [name, chunk] of [ + ["null", null], + ["undefined", none], + ["a number", 42], + ["an object", { not: "bytes" }], + ["an ArrayBuffer", new ArrayBuffer(3)], + ] as const) { + test(`(${name}) fails the readable with a TypeError, never as its end, and destroys the source with it`, async () => { + const source = new Recorded(); + const [, outcome] = read(source); + source.emit("data", "first"); + source.emit("data", chunk); + source.emit("data", "never read"); + source.emit("end"); + const failure = await outcome; + await ioTurn(); + // Only its guard listens + deepStrictEqual( + [ + failure instanceof TypeError, + codeOf(failure), + source.calls, + source.eventNames(), + ], + [ + true, + "ERR_INVALID_ARG_TYPE", + [["destroy", failure]], + ["error", "close"], + ], + ); + }); + } + + test("strings and bytes (a Buffer, any typed array, a DataView) are sent", async () => { + const source = new Recorded(); + const [readable, outcome] = read(source); + const chunks: Buffer[] = []; + readable.on("data", (chunk: Buffer) => chunks.push(chunk)); + source.emit("data", "a"); + source.emit("data", Buffer.from("b")); + source.emit("data", new Uint8Array([0x63])); + source.emit("data", new Uint16Array([0x6464])); + source.emit("data", new DataView(new Uint8Array([0x65]).buffer)); + source.emit("end"); + deepStrictEqual( + [await outcome, Buffer.concat(chunks).toString()], + ["ended", "abcdde"], + ); + }); + + test("sent: the response is cut, never framed complete, and the next request of the connection is not answered", async () => { + const outcomes: unknown[] = []; + const app = new Binden() + .use("/next", (context: Context) => context.send("next")) + .use(async (context: Context) => { + const source = new Recorded(); + setImmediate(() => { + source.emit("data", "abc"); + source.emit("data", null); + setImmediate(() => { + source.emit("data", "def"); + source.emit("end"); + }); + }); + outcomes.push( + await context.send(source).then( + () => "resolved", + (error: unknown) => codeOf(error), + ), + ); + }); + const listening = await listen(app); + try { + const socket = connect(listening.port, HOST); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + socket.on("error", () => null); + socket.write( + "GET / HTTP/1.1\r\nHost: a\r\n\r\nGET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + await within( + new Promise((resolve) => { + socket.once("close", resolve); + }), + ); + await pollUntil(() => outcomes.length === 1); + deepStrictEqual( + [ + outcomes, + received.endsWith("\r\n3\r\nabc\r\n"), + received.includes("next"), + ], + [["ERR_INVALID_ARG_TYPE"], true, false], + ); + } finally { + await listening.close(); + } + }); + }, +); + +suite( + "A stream-like source that emits an error while it is sent, without being destroyed", + () => { + test("is released: destroyed without its error, or paused without destroy()", async () => { + const failure = new Error("I/O failure"); + const destroyable = new Recorded(); + const pausable = new Pausable(); + const outcomes: unknown[] = []; + for (const source of [destroyable, pausable]) { + const [, outcome] = read(source); + source.emit("error", failure); + outcomes.push(await outcome); + } + deepStrictEqual( + [outcomes, destroyable.calls, pausable.calls], + [[failure, failure], [["destroy"]], [["pause"]]], + ); + }); + + test("of readable-stream 4 without autoDestroy: destroyed once the send rejects with its error", async () => { + const { Readable } = require("readable-stream-v4") as { + Readable: new (options: { + autoDestroy: boolean; + read(): void; + }) => EventEmitter & { + destroyed: boolean; + pause(): unknown; + resume(): unknown; + push(chunk: string): boolean; + }; + }; + const outcomes: unknown[] = []; + const sources: { destroyed: boolean }[] = []; + const app = new Binden().use(async (context: Context) => { + const source = new Readable({ + autoDestroy: false, + read(): void { + // Nothing to read + }, + }); + sources.push(source); + // The application listens + source.on("error", () => null); + setImmediate(() => { + source.push("abc"); + source.emit("error", new Error("I/O failure")); + }); + outcomes.push( + await context.send(source).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ), + ); + }); + const listening = await listen(app); + try { + const socket = connect(listening.port, HOST); + socket.on("error", () => null).resume(); + socket.write("GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"); + await within( + new Promise((resolve) => { + socket.once("close", resolve); + }), + ); + await pollUntil(() => outcomes.length === 1); + deepStrictEqual( + [outcomes, sources.map(({ destroyed }) => destroyed)], + [["I/O failure"], [true]], + ); + } finally { + await listening.close(); + } + }); + + test("not again once destroyed (by itself, or by the application), nor when its state cannot be read: logged", async () => { + const logs: unknown[] = []; + const destroyed = Object.assign(new Recorded(), { destroyed: false }); + const unreadable = new Recorded(); + let reads = 0; + Object.defineProperty(unreadable, "destroyed", { + get(): boolean { + reads += 1; + if (reads > 1) { + throw new Error("Its state cannot be read"); + } + return false; + }, + }); + const outcomes: unknown[] = []; + for (const source of [destroyed, unreadable]) { + const [, outcome] = read(source, logs); + if (source === destroyed) { + destroyed.destroyed = true; + } + source.emit("error", new Error("Failed")); + outcomes.push(await outcome); + } + await ioTurn(); + deepStrictEqual( + [ + outcomes.map((error) => (error as Error).message), + destroyed.calls, + unreadable.calls, + logs.map((error) => (error as Error).message), + ], + [["Failed", "Failed"], [], [], ["Its state cannot be read"]], + ); + }); + }, +); diff --git a/packages/binden/test/stream-like-settlement.test.ts b/packages/binden/test/stream-like-settlement.test.ts new file mode 100644 index 00000000..b3de6fe7 --- /dev/null +++ b/packages/binden/test/stream-like-settlement.test.ts @@ -0,0 +1,221 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { HOST, capture, ioTurn, listen, pollUntil, within } from "./helpers.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { connect } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; + +/** A stream-like source of its own */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } +} + +/** + * A source whose own `on()` emits an error at once when the second listener + * of its `error` is added (the one of its guard, once the send settles) + */ +class Emitting extends Source { + #errors = 0; + + public override on( + event: string, + listener: (...args: unknown[]) => void, + ): this { + if (event === "error") { + this.#errors += 1; + if (this.#errors === 2) { + this.emit("error", new Error("Emitted while guarded")); + } + } + return super.on(event, listener); + } +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +/** The messages of the late errors that a report has been given */ +function reporter(): [string[], (error: unknown) => void] { + const reported: string[] = []; + return [ + reported, + (error): void => { + reported.push((error as Error).message); + }, + ]; +} + +/** Sends `GET /` then `GET /next` on one connection, and reads until it closes */ +async function pipelined(port: number): Promise { + const socket = connect(port, HOST); + let received = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; + }); + socket.on("error", () => null); + socket.write( + "GET / HTTP/1.1\r\nHost: a\r\n\r\nGET /next HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + const closed = new Promise((resolve) => { + socket.once("close", resolve); + }); + try { + await within(closed); + } finally { + socket.destroy(); + } + return received; +} + +suite( + "A stream-like source whose code emits an error while its guard is installed", + () => { + test("once it has ended: the readable ends, and that error is reported once as a late error, never by the settled send", async () => { + const [reported, report] = reporter(); + const source = new Emitting(); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + report, + ); + const outcome = new Promise((resolve) => { + readable.once("end", () => { + resolve("ended"); + }); + readable.once("error", (error: Error) => { + resolve(error.message); + }); + }); + readable.resume(); + source.emit("data", "ok"); + source.emit("end"); + deepStrictEqual(await within(outcome), "ended"); + await ioTurn(); + deepStrictEqual(reported, ["Emitted while guarded"]); + }); + + test("once cancelled: that error is reported once as a late error", async () => { + const [reported, report] = reporter(); + const source = Object.assign(new Emitting(), { + destroy: (): null => null, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + report, + ); + readable.on("error", () => null).destroy(new Error("Cancelled")); + await ioTurn(); + deepStrictEqual(reported, ["Emitted while guarded"]); + }); + + test("sent whole: the reply is complete, the next one on the connection is sent, and that error is logged once", async () => { + const captured = capture(); + const outcomes: string[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }) + .use("/next", (context: Context) => context.send("next")) + .use(async (context: Context) => { + const source = new Emitting(); + setImmediate(() => { + source.emit("data", "ok"); + source.emit("end"); + }); + const sent = context.send(source); + outcomes.push( + await sent.then( + () => "resolved", + (error: unknown) => `rejected ${(error as Error).message}`, + ), + ); + }); + const listening = await listen(app); + try { + const received = await pipelined(listening.port); + deepStrictEqual( + [ + received.match(/HTTP\/1\.1 \d{3}/gu), + received.includes("\r\n2\r\nok\r\n0\r\n\r\n"), + received.endsWith("next"), + ], + [["HTTP/1.1 200", "HTTP/1.1 200"], true, true], + ); + await pollUntil(() => outcomes.length === 1); + await ioTurn(); + deepStrictEqual( + [ + outcomes, + captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message), + ], + [["resolved"], ["Emitted while guarded"]], + ); + } finally { + await listening.close(); + } + }); + }, +); + +suite( + "A stream-like source whose removal of the listener of `error` of the send throws", + () => { + for (const removes of [true, false]) { + test(`${removes ? "once it has removed it" : "before it has removed it"}: the send no longer hears it, and a later error is reported once`, async () => { + const [reported, report] = reporter(); + const source = Object.assign(new Source(), { + removeListener(event: string, listener: () => void): Source { + if (event === "error") { + if (removes) { + EventEmitter.prototype.removeListener.call( + source, + event, + listener, + ); + } + throw new Error("Removal failed"); + } + EventEmitter.prototype.removeListener.call(source, event, listener); + return source; + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + report, + ); + readable.resume(); + source.emit("end"); + // Once its listener of `error` is removed, on the next turn + await ioTurn(); + source.emit("error", new Error("Later")); + // Reported on a later turn + await ioTurn(); + deepStrictEqual( + [reported, source.listenerCount("error")], + // Its guard, and the listener of the send that is left + [["Removal failed", "Later"], removes ? 1 : 2], + ); + }); + } + }, +); diff --git a/packages/binden/test/stream-like-source-code.test.ts b/packages/binden/test/stream-like-source-code.test.ts new file mode 100644 index 00000000..4ad78aff --- /dev/null +++ b/packages/binden/test/stream-like-source-code.test.ts @@ -0,0 +1,140 @@ +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { ioTurn, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * A stream-like source of its own whose `destroyed` getter throws once armed, + * which records the values that it is destroyed with + */ +class Source extends EventEmitter { + public readonly destroys: unknown[] = []; + public readonly thrown = new Error("Its state cannot be read"); + public armed = false; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(error?: Error): this { + this.destroys.push(error); + return this; + } + + public get destroyed(): boolean { + if (this.armed) { + throw this.thrown; + } + return false; + } +} + +/** The error that a readable is destroyed with */ +function failureOf(readable: NodeJS.ReadableStream): Promise { + return within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); +} + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, with arguments, + * without a handler of the process: a crash (e.g. an error thrown out of + * `read()` of Node.js) is its exit code and output, not a failure of the test + * runner + */ +function fixture(name: string, ...args: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ...args, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite( + "Code of a stream-like source that the readable runs (R3.23-1, H23-2)", + () => { + test("its resume() (the first one, the first one with a stack getter that throws once read again, and the one once the readable has room again) or a getter of its destroyed once it ends or closes, throwing a value whose stack getter throws: the send rejects with it, without a handler of the process, and nothing is thrown into its events", () => { + deepStrictEqual( + fixture( + "unstackable-sources.ts", + "/resume", + "/resume-once", + "/drain", + "/end", + "/close", + ), + { + status: 0, + stdout: + "/resume 500 rejected:original threw= late=Later\n" + + "/resume-once 500 rejected:original threw= late=Later\n" + + // Once its headers are sent, the response is closed + "/drain 200 rejected:original threw= late=Later\n" + + "/end 500 rejected:original threw= late=Later\n" + + "/close 500 rejected:original threw= late=Later\n", + stderr: "", + }, + ); + }); + + test("a getter of its destroyed that throws once it ends or closes: the readable is destroyed with what it has thrown, the source is released with it, and the guard of one whose state tells that it has closed leaves", async () => { + for (const event of ["end", "close"] as const) { + // As the state of a Node.js `Readable` tells it (see `hasClosed()`) + const state = { closeEmitted: false }; + const source = Object.assign(new Source(), { _readableState: state }); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + ); + const failure = failureOf(readable); + source.armed = true; + state.closeEmitted = event === "close"; + source.emit(event); + const error = await failure; + await ioTurn(); + deepStrictEqual( + [ + error === source.thrown, + readable.readableEnded, + source.destroys.length, + source.destroys[0] === source.thrown, + // Its guard stays on one that has not closed + ["data", "end", "error", "close"].map((name) => + source.listenerCount(name), + ), + ], + [ + true, + false, + 1, + true, + event === "close" ? [0, 0, 0, 0] : [0, 0, 1, 1], + ], + event, + ); + } + }); + }, +); diff --git a/packages/binden/test/stream-like-state-guards.test.ts b/packages/binden/test/stream-like-state-guards.test.ts new file mode 100644 index 00000000..d6adbff9 --- /dev/null +++ b/packages/binden/test/stream-like-state-guards.test.ts @@ -0,0 +1,151 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, + uncarried, +} from "../src/stream-like.js"; +import { capture, ioTurn, listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; + +/** + * A stream-like source of its own whose getter of `destroyed` throws its + * value once it is armed (on every read, or on the first one only), and that + * emits another error from a listener of its `newListener` when the next + * listener of its `error` is added (its guard, once it is armed) + */ +class Source extends EventEmitter { + public readonly value = new Error("State failed"); + public readonly later = new Error("Emitted while its guard is installed"); + /** What its events have thrown into the code that emits them */ + public readonly threw: unknown[] = []; + readonly #once: boolean; + #armed = false; + #thrown = false; + + public constructor(once: boolean) { + super(); + this.#once = once; + this.on("newListener", (event: string) => { + if (this.#armed && event === "error" && !this.#thrown) { + this.#thrown = true; + this.emit("error", this.later); + } + }); + } + + public get destroyed(): boolean { + if (this.#armed && !(this.#once && this.#thrown)) { + throw this.value; + } + return false; + } + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(): this { + return this; + } + + /** Arms its getter, and emits its `end` or its `close`, recording what it throws */ + public stop(event: string): void { + this.#armed = true; + try { + this.emit(event); + } catch (error: unknown) { + this.threw.push(error); + } + } +} + +suite( + "A stream-like source whose getter of destroyed throws once it ends or closes, and that emits another error while its guard is installed (R24-C2)", + () => { + test("read as a readable: destroyed with what the getter has thrown, and the other error reported once, as a late one", async () => { + for (const event of ["end", "close"]) { + for (const once of [false, true]) { + const source = new Source(once); + const reports: unknown[] = []; + const released: unknown[] = []; + const log = (error: unknown): void => { + released.push(error); + }; + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + (error: unknown) => { + reports.push(error); + }, + () => null, + { released: log, removed: log }, + ); + const failure = within( + new Promise((resolve) => { + readable.once("error", resolve); + }), + ); + source.stop(event); + const error = await failure; + await ioTurn(); + deepStrictEqual( + [ + uncarried(error, readable) === source.value, + reports.length, + reports[0] === source.later, + released, + source.threw, + readable.closed, + ], + [true, 1, true, [], [], true], + `${event} ${String(once)}`, + ); + } + } + }); + + test("sent: the send rejects with what the getter has thrown, and the other error is logged once as a released failure", async () => { + for (const event of ["end", "close"]) { + const source = new Source(false); + const outcome = Promise.withResolvers(); + const captured = capture(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const sent = context.send(source).then( + () => "resolved", + (error: unknown) => error, + ); + source.stop(event); + outcome.resolve(await sent); + context.response.status(500).end("failed"); + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + const rejected = await within(outcome.promise); + await ioTurn(); + const late = captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); + deepStrictEqual( + [rejected === source.value, late, source.threw, status, body], + [true, [source.later.message], [], 500, "failed"], + event, + ); + } finally { + await listening.close(); + } + } + }); + }, +); diff --git a/packages/binden/test/stream-like-thrown-decisions.test.ts b/packages/binden/test/stream-like-thrown-decisions.test.ts new file mode 100644 index 00000000..0bfa0355 --- /dev/null +++ b/packages/binden/test/stream-like-thrown-decisions.test.ts @@ -0,0 +1,128 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { capture, ioTurn, listen, pollUntil, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; + +/** A stream-like source of its own */ +class Source extends EventEmitter { + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(): this { + return this; + } +} + +/** The message of each late error logged so far */ +function lateOf(records: readonly Record[]): unknown[] { + return records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +/** A chunk that the response does not take at once (its `write()` returns `false`) */ +const big = Buffer.alloc(1 << 20, 0x61); + +suite( + "An error that code of a stream-like source throws, which the send does not reject with (R23-C1, H23-3)", + () => { + test("its resume() throws it, or it emits it, while the cancellation of the send is pending (a failed error handler): the send rejects with the reason, and that error is logged once, as a late error", async () => { + for (const mode of ["thrown", "emitted"] as const) { + const captured = capture(); + const failure = new Error("Source failure"); + const outcomes: unknown[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + error_handler(context: Context): never { + const source = Object.assign(new Source(), { + resume(): Source { + if (mode === "thrown") { + throw failure; + } + return source; + }, + }); + context.send(source).then( + () => outcomes.push("resolved"), + (error: unknown) => outcomes.push((error as Error).message), + ); + if (mode === "emitted") { + process.nextTick(() => { + source.emit("error", failure); + }); + } + throw new Error("Handler failure"); + }, + }).use((): never => { + throw new Error("Middleware failure"); + }); + const listening = await listen(app); + try { + const { status } = await send(listening.port); + await pollUntil(() => outcomes.length === 1); + await ioTurn(); + deepStrictEqual( + [status, outcomes, lateOf(captured.records)], + [500, ["Handler failure"], ["Source failure"]], + mode, + ); + } finally { + await listening.close(); + } + } + }); + + test("its pause() ends the response, then throws it, or emits it: the send settles as the response does, and that error is logged once, as a late error", async () => { + for (const mode of ["thrown", "emitted"] as const) { + const captured = capture(); + const failure = new Error("Source failure"); + const outcomes: unknown[] = []; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + const source = Object.assign(new Source(), { + pause(): Source { + context.response.end("Ended by the source"); + if (mode === "thrown") { + throw failure; + } + source.emit("error", failure); + return source; + }, + }); + setImmediate(() => { + // Written at once, then kept by the readable, which pauses the + // source + source.emit("data", big); + source.emit("data", big); + }); + await context.send(source).then( + () => outcomes.push("resolved"), + (error: unknown) => outcomes.push((error as Error).message), + ); + }); + const listening = await listen(app); + try { + await send(listening.port).catch(() => null); + await pollUntil(() => outcomes.length === 1); + await ioTurn(); + deepStrictEqual( + [outcomes, lateOf(captured.records)], + [["resolved"], ["Source failure"]], + mode, + ); + } finally { + await listening.close(); + } + } + }); + }, +); diff --git a/packages/binden/test/stream-like.test.ts b/packages/binden/test/stream-like.test.ts new file mode 100644 index 00000000..88f9df58 --- /dev/null +++ b/packages/binden/test/stream-like.test.ts @@ -0,0 +1,1617 @@ +import { + Binden, + type BindenAbortError, + BindenResponse, + type Context, + type IBindenOptions, + type IStreamLike, + releasedFailure, +} from "../index.js"; +import { EventEmitter, once } from "node:events"; +import { + type IListening, + capture, + listen, + pollUntil, + sleep, + within, +} from "./helpers.js"; +import { + type IStreamLikeMethods, + emitterMethods, + guard, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { IncomingMessage, request as httpRequest } from "node:http"; +import { Socket, connect } from "node:net"; +import { deepStrictEqual, ok, rejects } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { kSeal } from "../src/seal.js"; +import { spawnSync } from "node:child_process"; + +const require = createRequire(import.meta.url); + +/** The source of a library: a transform whose readable side `send()` reads */ +interface ISource extends EventEmitter { + readonly destroyed?: boolean; + write(chunk: Buffer | string): boolean; + end(chunk?: Buffer | string): void; + pause(): this; + resume(): this; + isPaused(): boolean; + destroy(error?: Error): void; +} + +/** A transform of `readable-stream` */ +type IPassThrough = new () => ISource; + +/** `through2` 2 (a transform of `readable-stream` 2) */ +const through2 = require("through2-v2") as ( + transform: ( + chunk: Buffer, + encoding: string, + callback: (error: null, chunk: Buffer) => void, + ) => void, +) => ISource; + +/** The sources of every library, none of them a Node.js `Readable` */ +const libraries: readonly (readonly [string, () => ISource])[] = [ + ...(["2", "3", "4"] as const).map( + (version) => + [ + `readable-stream ${version}`, + (): ISource => { + const { PassThrough } = require(`readable-stream-v${version}`) as { + PassThrough: IPassThrough; + }; + return new PassThrough(); + }, + ] as const, + ), + [ + "through2 2", + (): ISource => + through2((chunk, _encoding, callback) => { + callback(null, chunk); + }), + ], +]; + +/** The listeners that `send()` adds to a source, how often it is paused, and what it is destroyed with */ +interface ISpy { + readonly added: [string, unknown][]; + readonly pauses: number; + readonly destroys: unknown[]; + /** The listeners added that the source still has */ + left(): [string, unknown][]; +} + +/** Records the listeners added to a source (by its own `on()`) and its pauses */ +function spy(source: ISource): ISpy { + const added: [string, unknown][] = []; + const destroys: unknown[] = []; + let pauses = 0; + const original = { + on: source.on.bind(source), + pause: source.pause.bind(source), + destroy: source.destroy.bind(source), + }; + Object.assign(source, { + on(event: string, listener: () => void): ISource { + added.push([event, listener]); + original.on(event, listener); + return source; + }, + pause(): ISource { + pauses += 1; + return original.pause(); + }, + destroy(error?: Error): void { + destroys.push(error); + original.destroy(error); + }, + }); + return { + added, + destroys, + get pauses(): number { + return pauses; + }, + left: (): [string, unknown][] => + added.filter(([event, listener]) => + source.rawListeners(event).includes(listener as () => void), + ), + }; +} + +/** Whether a source has been destroyed (`through2` 2 sets only `_destroyed`) */ +function destroyed(source: ISource): boolean { + return ( + source.destroyed === true || Reflect.get(source, "_destroyed") === true + ); +} + +/** + * Whether the sources of a library close once they have ended + * (`autoDestroy`): only `readable-stream` 4. The guard that a send leaves on + * a source (see `guard()`) is removed on the turn after its close: it stays + * on the others. + */ +function closesAtEnd(library: string): boolean { + return library === "readable-stream 4"; +} + +/** + * Whether the state of the sources of a library tells that they have closed + * (`closeEmitted`): only `readable-stream` 4. The guard that a send leaves on + * a source (see `guard()`) is removed on the turn after its close only then: + * the others keep it as long as they live (they may emit an error after + * their close). + */ +function tellsClosed(library: string): boolean { + return library === "readable-stream 4"; +} + +/** The listeners of the guard of a source that keeps it once closed (see `tellsClosed()`) */ +function keptOf(library: string): string[] { + return tellsClosed(library) ? [] : ["error", "close"]; +} + +/** The events of the listeners left on a source, in the order they were added */ +function leftOf(recorded: ISpy): string[] { + return recorded.left().map(([event]) => event); +} + +/** A response as the client receives it, with the bytes of its body */ +interface IBytes { + readonly status: number; + readonly headers: Record; + readonly body: Buffer; +} + +/** Sends a request on a new connection, and reads the bytes of its response */ +function fetchBytes( + port: number, + { method = "GET", path = "/" }: { method?: string; path?: string } = {}, +): Promise { + return new Promise((resolve, reject) => { + httpRequest( + { + agent: false, + method, + path, + host: "127.0.0.1", + port, + signal: AbortSignal.timeout(10_000), + }, + (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(); + }); +} + +/** Sends a `GET` on a new connection, and reads what it receives until the connection closes */ +function fetchRaw(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1"); + let data = ""; + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`Not closed within 5000 ms: ${data}`)); + }, 5000); + socket + .setEncoding("latin1") + .on("data", (chunk: string) => { + data += chunk; + }) + // E.g. a reset once the response is cut + .on("error", () => null) + .once("close", () => { + clearTimeout(timer); + resolve(data); + }) + .write("GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n"); + }); +} + +/** Runs an application with a middleware until the client is done */ +async function withApp( + options: IBindenOptions, + middleware: (context: Context) => Promise, + client: (listening: IListening) => Promise, +): Promise { + const listening = await listen(new Binden(options).use(middleware)); + try { + await client(listening); + } finally { + await listening.close(); + } +} + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, with these flags + * of Node.js: a crash (e.g. an unhandled `error` event) is its exit code and + * output, not a failure of the test runner + */ +function fixture(name: string, ...flags: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + ...flags, + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +/** The next turn: once the deferred `error` listener of a source is removed */ +function nextTurn(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + +/** The error that a source has been destroyed with, by an `error` listener of the application */ +function errorOf(source: ISource): () => unknown { + let error: unknown = null; + source.on("error", (failure: unknown) => { + error = failure; + }); + return () => error; +} + +/** The bytes of the body: every value of a byte, multi-byte characters, and more than a chunk */ +const parts = [ + Buffer.from(Array.from({ length: 256 }, (_value, index) => index)), + "hé€😀", + Buffer.alloc(100_000, 0x61), +]; +const whole = Buffer.concat( + parts.map((part) => (typeof part === "string" ? Buffer.from(part) : part)), +); + +for (const [library, create] of libraries) { + suite(`send() of a stream of ${library}`, () => { + for (const declared of [false, true]) { + test(`sends the bytes of the source${declared ? " of its declared length" : ""}, and removes its listeners: its guard stays until the turn after its close`, async () => { + const source = create(); + const recorded = spy(source); + const outcomes: unknown[] = []; + await withApp( + {}, + async (context: Context) => { + if (declared) { + context.response.setHeader("Content-Length", whole.length); + } + setTimeout(() => { + for (const part of parts) { + source.write(part); + } + source.end(); + }, 10); + await context.send(source); + await pollUntil( + () => leftOf(recorded).length === (closesAtEnd(library) ? 0 : 2), + ); + outcomes.push(recorded.added.map(([event]) => event).sort()); + // By `send()` (`readable-stream` 4 destroys itself once ended) + outcomes.push( + leftOf(recorded), + recorded.destroys.filter((error) => error !== none), + ); + }, + async ({ port }) => { + const received = await fetchBytes(port); + deepStrictEqual(received.status, 200); + ok(received.body.equals(whole), String(received.body.length)); + deepStrictEqual( + received.headers["content-length"], + declared ? String(whole.length) : none, + ); + await pollUntil(() => outcomes.length === 3); + }, + ); + // Never destroyed once it has ended: its guard listens before the + // listeners of the send are removed + deepStrictEqual(outcomes, [ + ["close", "close", "data", "end", "error", "error"], + closesAtEnd(library) ? [] : ["error", "close"], + [], + ]); + }); + } + + test("destroys the source with the error of a body longer than its declared length", async () => { + const source = create(); + const recorded = spy(source); + source.on("error", () => null); + await withApp( + {}, + async (context: Context) => { + context.response.setHeader("Content-Length", 3); + source.write("abcd"); + await context.send(source); + }, + async ({ port }) => { + // Before any byte: the error is replied + deepStrictEqual((await fetchBytes(port)).status, 500); + await pollUntil(() => recorded.destroys.length > 0); + deepStrictEqual( + recorded.destroys.map( + (error) => (error as { code?: unknown }).code, + ), + ["ERR_HTTP_CONTENT_LENGTH_MISMATCH"], + ); + }, + ); + }); + + test("pauses the source while the response is full, and resumes it once it drains", async () => { + const source = create(); + const recorded = spy(source); + const chunk = Buffer.alloc(65_536, 0x62); + const producer = { written: 0, blocked: false }; + await withApp( + {}, + async (context: Context) => { + // As much as the source takes: once paused, it takes no more + const produce = (): void => { + while (producer.written < 256 * chunk.length) { + producer.written += chunk.length; + if (!source.write(chunk)) { + producer.blocked = true; + source.once("drain", () => { + producer.blocked = false; + produce(); + }); + return; + } + } + source.end(); + }; + produce(); + await context.send(source); + }, + async ({ port }) => { + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let received = 0; + socket.on("data", (data: Buffer) => { + received += data.length; + }); + socket.write( + "GET / HTTP/1.1\r\nHost: a\r\nConnection: close\r\n\r\n", + ); + socket.pause(); + // The client reads nothing: the response fills up, then the + // source + await pollUntil( + () => + producer.blocked && recorded.pauses > 0 && source.isPaused(), + 5000, + ); + // Until the buffers of the source are full (a stream of + // readable-stream may still drain its writable side into its + // readable one once paused) + let pending = -1; + for (let tries = 0; pending !== producer.written; tries += 1) { + ok(tries < 50, "the source never stops taking chunks"); + pending = producer.written; + await sleep(100); + } + await sleep(50); + // Paused: it takes nothing more + deepStrictEqual( + [producer.written, producer.blocked, source.isPaused()], + [pending, true, true], + ); + socket.resume(); + await within(once(socket, "close"), 10_000); + // Everything, once the client reads + ok(received > producer.written, String(received)); + deepStrictEqual(producer.written, 256 * chunk.length); + } finally { + // Also when it fails: a paused socket would never see its end + socket.destroy(); + } + }, + ); + }); + + test("destroys the source with the reason of the deadline, and removes its listeners (its guard once its state tells that it has closed)", async () => { + const source = create(); + const reason = errorOf(source); + const recorded = spy(source); + await withApp( + { timeout: 50 }, + async (context: Context) => { + await context.send(source); + }, + async ({ port }) => { + const received = await fetchBytes(port); + deepStrictEqual(received.status, 503); + await pollUntil(() => destroyed(source) && reason() !== null); + await pollUntil( + () => leftOf(recorded).join() === keptOf(library).join(), + ); + deepStrictEqual((reason() as BindenAbortError).reason, "deadline"); + // Of the send, and of its guard + deepStrictEqual(recorded.added.map(([event]) => event).sort(), [ + "close", + "close", + "data", + "end", + "error", + "error", + ]); + }, + ); + }); + + test("destroys the source once the client goes away, and removes its listeners", async () => { + const source = create(); + const reason = errorOf(source); + const recorded = spy(source); + const sent = Promise.withResolvers(); + await withApp( + {}, + async (context: Context) => { + source.write("part"); + sent.resolve( + await context.send(source).then( + () => null, + (error: unknown) => error, + ), + ); + }, + async ({ port }) => { + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write("GET / HTTP/1.1\r\nHost: a\r\n\r\n"); + await pollUntil(() => data.includes("part")); + } finally { + socket.destroy(); + } + const error = await within(sent.promise); + ok(error instanceof Error); + await pollUntil(() => destroyed(source) && reason() !== null); + await nextTurn(); + deepStrictEqual( + (reason() as BindenAbortError).reason, + "client_closed", + ); + // Its guard, unless its state tells that it has closed + deepStrictEqual(leftOf(recorded), keptOf(library)); + }, + ); + }); + + test("sends an empty body for a source that has ended, without reading it: only its guard listens (until the turn after its close, once its state tells it)", async () => { + const source = create(); + source.end(); + source.resume(); + await pollUntil( + () => + Reflect.get( + Reflect.get(source, "_readableState") as object, + "endEmitted", + ) === true, + ); + const recorded = spy(source); + await withApp( + {}, + async (context: Context) => { + await context.send(source); + }, + async ({ port }) => { + const received = await fetchBytes(port); + deepStrictEqual( + [received.status, received.body.toString()], + [200, ""], + ); + await nextTurn(); + deepStrictEqual( + [ + recorded.added.map(([event]) => event), + leftOf(recorded), + recorded.destroys, + ], + [ + ["error", "close"], + // Of `readable-stream` 4: `closeEmitted` + closesAtEnd(library) ? [] : ["error", "close"], + [], + ], + ); + }, + ); + }); + + test("rejects at once for a source that has been destroyed, without reading it: only its guard listens (until the turn after its close, once its state tells it)", async () => { + const source = create(); + source.destroy(); + await nextTurn(); + const recorded = spy(source); + const captured = capture(); + await withApp( + { logger: captured.logger }, + async (context: Context) => { + await context.send(source); + }, + async ({ port }) => { + deepStrictEqual((await fetchBytes(port)).status, 500); + const { failure } = await captured.waitFor("The request has failed"); + deepStrictEqual( + (failure as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + await nextTurn(); + deepStrictEqual( + [recorded.added.map(([event]) => event), recorded.destroys], + [["error", "close"], []], + ); + // Of `readable-stream` 4: `closeEmitted` + deepStrictEqual( + recorded.left().length, + library === "readable-stream 4" ? 0 : 2, + ); + }, + ); + }); + + for (const [how, fail] of [ + [ + "closes before its end", + (source: ISource): void => { + source.destroy(); + }, + ], + [ + "fails", + (source: ISource): void => { + source.emit( + "error", + Object.assign(new Error("Failed"), { code: "E_FAILED" }), + ); + }, + ], + ] as const) { + // Also a `PassThrough` of `readable-stream` 2, which emits its `end` + // once destroyed: not its end + test(`rejects when the source ${how}, and removes its listeners: its guard stays until the turn after its close, once its state tells it`, async () => { + const source = create(); + // The error of the application, which `readable-stream` 2 emits + // once closed + source.on("error", () => null); + const recorded = spy(source); + let rejected: unknown = null; + await withApp( + {}, + async (context: Context) => { + setTimeout(() => { + fail(source); + }, 10); + await context.send(source).catch((error: unknown) => { + rejected = error; + throw error; + }); + }, + async ({ port }) => { + // Before any byte: the error is replied + const received = await fetchBytes(port); + deepStrictEqual( + [received.status, received.body.toString()], + [500, ""], + ); + deepStrictEqual( + (rejected as { code?: unknown } | null)?.code, + how === "fails" ? "E_FAILED" : "ERR_STREAM_PREMATURE_CLOSE", + ); + await nextTurn(); + // Its guard, unless its state tells that it has closed: also + // once it has failed, which destroys it (without its error) + deepStrictEqual(leftOf(recorded), keptOf(library)); + }, + ); + }); + } + + // `readable-stream` 2 emits its `end` and its `close` once destroyed, + // then the error of its destroy: neither is its end + for (const [when, first] of [ + ["before its first chunk", false], + ["once its first chunk is sent", true], + ] as const) { + for (const failure of first ? [true, false] : [true]) { + test(`rejects when the source is destroyed ${failure ? "with an error" : "without an error"} ${when}: ${first ? "the connection is closed" : "the error is replied"}, never a complete body`, async () => { + const source = create(); + const recorded = spy(source); + const error = Object.assign(new Error("Destroyed"), { + code: "E_DESTROYED", + }); + let rejected: unknown = null; + await withApp( + {}, + async (context: Context) => { + if (first) { + source.write("first;"); + } + setTimeout(() => { + source.destroy(failure ? error : none); + }, 20); + await context.send(source).catch((reason: unknown) => { + rejected = reason; + throw reason; + }); + }, + async ({ port }) => { + const received = await fetchRaw(port); + if (first) { + // Cut after its chunk: without the last chunk + ok(received.startsWith("HTTP/1.1 200 OK\r\n"), received); + ok(received.endsWith("\r\n\r\n6\r\nfirst;\r\n"), received); + } else { + ok( + received.startsWith("HTTP/1.1 500 Internal Server Error\r\n"), + received, + ); + } + await pollUntil(() => rejected !== null); + deepStrictEqual( + (rejected as { code?: unknown }).code, + failure ? "E_DESTROYED" : "ERR_STREAM_PREMATURE_CLOSE", + ); + await nextTurn(); + // Its guard, unless its state tells that it has closed + deepStrictEqual(leftOf(recorded), keptOf(library)); + }, + ); + }); + } + } + + for (const [method, status] of [ + ["HEAD", 200], + ["GET", 204], + ["GET", 304], + ] as const) { + test(`sends no body to ${method} with ${status}: the source is destroyed without being read, guarded until it has closed (as long as it lives, unless its state tells it)`, async () => { + const source = create(); + source.write("unread"); + const recorded = spy(source); + await withApp( + {}, + async (context: Context) => { + await context.status(status).send(source); + }, + async ({ port }) => { + const received = await fetchBytes(port, { method }); + deepStrictEqual( + [received.status, received.body.toString()], + [status, ""], + ); + await pollUntil(() => destroyed(source)); + await pollUntil( + () => leftOf(recorded).join() === keptOf(library).join(), + ); + // Only by its guard + deepStrictEqual(recorded.added.map(([event]) => event).sort(), [ + "close", + "error", + ]); + }, + ); + }); + } + }); +} + +/** A stream-like source of its own: an emitter with `on()`, `pause()`, `resume()` and `off()` */ +class Emitting extends EventEmitter { + public readonly calls: string[] = []; + + public pause(): this { + this.calls.push("pause"); + return this; + } + + public resume(): this { + this.calls.push("resume"); + return this; + } +} + +/** A stream-like source whose `destroy()` emits nothing (it is not required to) */ +class Silent extends Emitting { + public destroy(): this { + this.calls.push("destroy"); + return this; + } +} + +/** The listeners that a source has of its `data`, `end`, `error` and `close` */ +function counts(source: EventEmitter): number[] { + return ["data", "end", "error", "close"].map((event) => + source.listenerCount(event), + ); +} + +/** The messages of the late errors of released sources that a logger has logged */ +function reported(captured: ReturnType): unknown[] { + return captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); +} + +suite("send() of a stream-like source", () => { + test("without destroy(): paused once cancelled or released, guarded before (an error of its pause is reported), and the listeners of the send are removed", async () => { + const captured = capture(); + /** The events whose listeners have been removed, by source */ + const removed = new Map(); + /** A source without `destroy()`, with only `off()`, whose pause fails */ + const create = (): Emitting => { + const source: Emitting = Object.assign(new Emitting(), { + removeListener: null, + off(event: string, listener: () => void): Emitting { + removed.get(source)?.push(event); + EventEmitter.prototype.removeListener.call(source, event, listener); + return source; + }, + pause(): Emitting { + source.calls.push("pause"); + source.emit("error", new Error("Failed to pause")); + return source; + }, + }); + removed.set(source, []); + return source; + }; + const [cancelled, bodiless] = [create(), create()]; + await withApp( + { logger: captured.logger, log_error_details: true, timeout: 50 }, + async (context: Context) => { + await context.send( + context.request.method === "HEAD" ? bodiless : cancelled, + ); + }, + async ({ port }) => { + deepStrictEqual( + [ + (await fetchBytes(port)).status, + (await fetchBytes(port, { method: "HEAD" })).status, + ], + [503, 200], + ); + await pollUntil(() => reported(captured).length === 2); + deepStrictEqual(reported(captured), [ + "Failed to pause", + "Failed to pause", + ]); + await nextTurn(); + // Its guard stays (it never closes) + deepStrictEqual( + [counts(cancelled), counts(bodiless)], + [ + [0, 0, 1, 1], + [0, 0, 1, 1], + ], + ); + deepStrictEqual( + [cancelled.calls, bodiless.calls], + [["resume", "pause"], ["pause"]], + ); + deepStrictEqual( + [removed.get(cancelled)?.sort(), removed.get(bodiless)], + [["close", "data", "end", "error"], []], + ); + }, + ); + }); + + test("whose destroy() emits nothing: once cancelled, the listeners of the send are removed at once, and only its guard stays, which reports a late error", async () => { + const source = new Silent(); + const captured = capture(); + const sent = Promise.withResolvers(); + await withApp( + { logger: captured.logger, log_error_details: true, timeout: 50 }, + async (context: Context) => { + sent.resolve( + await context.send(source).then( + () => null, + (error: unknown) => error, + ), + ); + }, + async ({ port }) => { + deepStrictEqual((await fetchBytes(port)).status, 503); + const reason = await within(sent.promise); + deepStrictEqual((reason as BindenAbortError).reason, "deadline"); + deepStrictEqual(source.calls, ["resume", "destroy"]); + // Its guard: of `error` and `close` + deepStrictEqual(counts(source), [0, 0, 1, 1]); + source.emit("error", new Error("Late")); + await captured.waitFor(releasedFailure); + deepStrictEqual(reported(captured), ["Late"]); + // Its state cannot tell that it has closed: it stays + source.emit("close"); + await nextTurn(); + deepStrictEqual(counts(source), [0, 0, 1, 1]); + // Until the turn after a close that its state tells + Object.assign(source, { _readableState: { closeEmitted: true } }); + source.emit("close"); + deepStrictEqual(counts(source), [0, 0, 1, 1]); + await nextTurn(); + deepStrictEqual(source.eventNames(), []); + }, + ); + }); + + test("shared by sends that release it or are cancelled: one guard", async () => { + const source = new Silent(); + await withApp( + { timeout: 20 }, + async (context: Context) => { + // Released by `HEAD`, cancelled by the deadline otherwise + await context.send(source); + }, + async ({ port }) => { + for (let round = 0; round < 5; round += 1) { + const [head, get] = [ + await fetchBytes(port, { method: "HEAD" }), + await fetchBytes(port), + ]; + deepStrictEqual([head.status, get.status], [200, 503]); + } + deepStrictEqual(counts(source), [0, 0, 1, 1]); + deepStrictEqual( + source.calls.filter((call) => call === "destroy").length, + 10, + ); + }, + ); + }); + + test("to HEAD: its guard reports an error emitted after its close within the turn, and is removed once its state tells that it has closed (each removal that throws is logged as a removal, not as a late error)", async () => { + const captured = capture(); + const removed: string[] = []; + // Closed twice, then an error + const closing = Object.assign(new Emitting(), { + destroy(): void { + Object.assign(closing, { _readableState: { closeEmitted: true } }); + closing.emit("close"); + closing.emit("close"); + process.nextTick(() => { + closing.emit("error", new Error("After close")); + }); + }, + removeListener(event: string, listener: () => void): Emitting { + removed.push(event); + return EventEmitter.prototype.removeListener.call( + closing, + event, + listener, + ) as Emitting; + }, + }); + const stuck = Object.assign(new Emitting(), { + destroy(): void { + Object.assign(stuck, { _readableState: { closeEmitted: true } }); + stuck.emit("close"); + }, + removeListener(): never { + throw new Error("Cannot remove"); + }, + }); + await withApp( + { logger: captured.logger, log_error_details: true }, + async (context: Context) => { + await context.send(context.url.pathname === "/stuck" ? stuck : closing); + }, + async ({ port }) => { + for (const path of ["/", "/stuck"]) { + const received = await fetchBytes(port, { method: "HEAD", path }); + deepStrictEqual(received.status, 200); + } + await nextTurn(); + deepStrictEqual( + [ + reported(captured), + captured.records + .filter( + ({ msg }) => + msg === "The removal of a listener of a stream has failed", + ) + .map(({ failure }) => (failure as { message?: unknown }).message), + ], + [["After close"], ["Cannot remove", "Cannot remove"]], + ); + deepStrictEqual( + [closing.eventNames(), removed], + [[], ["error", "close"]], + ); + }, + ); + }); + + test("sends its data, read with its own methods", async () => { + // As the types of `send()` take it + const source: Emitting & IStreamLike = new Emitting(); + await withApp( + {}, + async (context: Context) => { + setTimeout(() => { + source.emit("data", Buffer.from("a")); + source.emit("data", "b"); + source.emit("end"); + }, 10); + await context.send(source); + }, + async ({ port }) => { + const received = await fetchBytes(port); + deepStrictEqual( + [received.status, received.body.toString()], + [200, "ab"], + ); + // Resumed once read + ok(source.calls.includes("resume")); + }, + ); + }); + + test("that has ended (`readableEnded`) is an empty body: its guard reports a later error (e.g. of a destroy by autoDestroy) until the turn after its close, once its state tells it", async () => { + const source = Object.assign(new Emitting(), { readableEnded: true }); + const captured = capture(); + await withApp( + { logger: captured.logger, log_error_details: true }, + async (context: Context) => { + await context.send(source); + }, + async ({ port }) => { + const received = await fetchBytes(port); + deepStrictEqual([received.status, received.body.toString()], [200, ""]); + deepStrictEqual( + [source.eventNames(), source.calls], + [["error", "close"], []], + ); + source.emit("error", new Error("Its destroy has failed")); + Object.assign(source, { _readableState: { closeEmitted: true } }); + source.emit("close"); + await nextTurn(); + deepStrictEqual( + [reported(captured), source.eventNames()], + [["Its destroy has failed"], []], + ); + }, + ); + }); + + test("an end or a close once destroyed is not its end: the error emitted within the turn rejects (not reported), and a later one is reported once (by the send until its listener is removed, then by its guard until the turn after a close that its state tells)", async () => { + for (const [flag, event] of [ + ["destroyed", "end"], + ["_destroyed", "close"], + ] as const) { + const source = new Emitting(); + const captured = capture(); + let rejected: unknown = null; + await withApp( + { logger: captured.logger, log_error_details: true }, + async (context: Context) => { + setTimeout(() => { + Object.assign(source, { [flag]: true }); + if (event === "close") { + Object.assign(source, { _readableState: { closeEmitted: true } }); + } + source.emit(event); + process.nextTick(() => { + const error = new Error("Destroyed"); + source.emit("error", Object.assign(error, { code: "E_GONE" })); + }); + }, 10); + await context.send(source).catch((error: unknown) => { + rejected = error; + // Before the listener of the send is removed + source.emit("error", new Error("Decided")); + throw error; + }); + }, + async ({ port }) => { + deepStrictEqual((await fetchBytes(port)).status, 500); + deepStrictEqual((rejected as { code?: unknown }).code, "E_GONE"); + await nextTurn(); + if (event === "end") { + // Its guard, which has not seen its close + deepStrictEqual(counts(source), [0, 0, 1, 1]); + source.emit("error", new Error("Later")); + Object.assign(source, { _readableState: { closeEmitted: true } }); + source.emit("close"); + await nextTurn(); + } + // The error of the send is not reported + deepStrictEqual( + [reported(captured), source.eventNames()], + [event === "end" ? ["Decided", "Later"] : ["Decided"], []], + flag, + ); + }, + ); + } + }); + + test("destroyed, then cancelled within the turn: it is not destroyed again, only guarded", async () => { + const source = new Silent(); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + () => null, + ); + const failure = once(readable, "error"); + readable.resume(); + Object.assign(source, { destroyed: true }); + source.emit("end"); + readable.destroy(new Error("Cancelled")); + deepStrictEqual(((await failure)[0] as Error).message, "Cancelled"); + await nextTurn(); + // Neither destroyed nor paused + deepStrictEqual(source.calls, []); + // Its guard, until it closes + deepStrictEqual(counts(source), [0, 0, 1, 1]); + }); + + test("removes each of its listeners once, and its guard (which listens before) on the turn after a close that its state tells: an error after its end is reported once", async () => { + const source = new Emitting(); + const reports: unknown[] = []; + const removed: string[] = []; + Object.assign(source, { + removeListener(event: string, listener: () => void): Emitting { + removed.push(event); + EventEmitter.prototype.removeListener.call(source, event, listener); + return source; + }, + }); + const readable = readStreamLike( + source, + streamLikeOf(source) as IStreamLikeMethods, + () => new Error("Cancelled"), + (error) => reports.push((error as Error).message), + ); + readable.on("error", () => null).resume(); + // Its `error` listener is removed on the next turn: an error before is + // reported by it (not by its guard) + source.emit("end"); + source.emit("error", new Error("Failed")); + await nextTurn(); + deepStrictEqual(removed.sort(), ["close", "data", "end", "error"]); + deepStrictEqual(source.eventNames(), ["error", "close"]); + // By its guard (e.g. a destroy by autoDestroy that fails later), which + // would otherwise crash the process + source.emit("error", new Error("Failed later")); + Object.assign(source, { _readableState: { closeEmitted: true } }); + source.emit("close"); + await nextTurn(); + deepStrictEqual( + [reports, removed.sort(), source.eventNames()], + [ + ["Failed", "Failed later"], + ["close", "close", "data", "end", "error", "error"], + [], + ], + ); + }); + + test("of chunks that are not bytes rejects", async () => { + const source = new Emitting(); + const captured = capture(); + await withApp( + { logger: captured.logger }, + async (context: Context) => { + setTimeout(() => { + source.emit("data", { not: "bytes" }); + }, 10); + await context.send(source); + }, + async ({ port }) => { + deepStrictEqual((await fetchBytes(port)).status, 500); + const { failure } = await captured.waitFor("The request has failed"); + deepStrictEqual( + (failure as { code?: unknown }).code, + "ERR_INVALID_ARG_TYPE", + ); + deepStrictEqual(source.listenerCount("data"), 0); + }, + ); + }); +}); + +/** + * A `Proxy` (of `target`, an empty object by default) whose every inspection + * is recorded and throws (e.g. a getter of `on` that throws): `send()` must + * not read it + */ +function untouchable(traps: string[], target: object = {}): object { + const handler: ProxyHandler = {}; + for (const trap of [ + "get", + "has", + "getPrototypeOf", + "getOwnPropertyDescriptor", + "ownKeys", + ] as const) { + handler[trap] = (): never => { + traps.push(trap); + throw new Error(`The source has been read (${trap})`); + }; + } + return new Proxy(target, handler); +} + +suite("send() of a stream-like source that will not be sent", () => { + test("with an informational status: destroys it (or pauses one without destroy()) without reading it, guarded until it has closed", async () => { + const [, create] = libraries[2] as readonly [string, () => ISource]; + const source = create(); + const recorded = spy(source); + const emitting = new Emitting(); + for (const sent of [source, emitting]) { + const response = new BindenResponse(new IncomingMessage(new Socket())); + await rejects(response.status(103).send(sent), TypeError); + } + ok(destroyed(source)); + await pollUntil(() => recorded.left().length === 0); + deepStrictEqual(recorded.added.map(([event]) => event).sort(), [ + "close", + "error", + ]); + deepStrictEqual(emitting.calls, ["pause"]); + // Its guard, until it closes + deepStrictEqual(counts(emitting), [0, 0, 1, 1]); + // Without the framework: its error is not reported, nor thrown + const failing = Object.assign(new Emitting(), { + destroy(): void { + failing.emit("error", new Error("Failed to close")); + }, + }); + const response = new BindenResponse(new IncomingMessage(new Socket())); + await rejects(response.status(103).send(failing), TypeError); + // Its guard, until it closes + deepStrictEqual(counts(failing), [0, 0, 1, 1]); + }); + + test("once the response has ended, or by a work that may no longer mutate it: resolves without touching it", async () => { + const [, create] = libraries[2] as readonly [string, () => ISource]; + const traps: string[] = []; + const ended = (): BindenResponse => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response.end(); + return response; + }; + const revoked = (): BindenResponse => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response[kSeal](() => false); + return response; + }; + for (const respond of [ended, revoked]) { + const source = create(); + const recorded = spy(source); + const emitting = new Emitting(); + for (const sent of [source, emitting, untouchable(traps)]) { + // @ts-expect-error -- an object that `send()` must not read + await respond().send(sent); + } + ok(!destroyed(source)); + deepStrictEqual( + [recorded.added, recorded.pauses, emitting.calls], + [[], 0, []], + ); + } + deepStrictEqual(traps, []); + }); + + test("once the deadline has passed: a late send resolves without touching it", async () => { + const traps: string[] = []; + const late = Promise.withResolvers(); + await withApp( + { timeout: 20 }, + async (context: Context) => { + await once(context.signal, "abort"); + await sleep(10); + late.resolve( + await context.response + .send( + // @ts-expect-error -- an object that `send()` must not read + untouchable(traps), + ) + .then( + () => "resolved", + (error: unknown) => error, + ), + ); + }, + async ({ port }) => { + deepStrictEqual((await fetchBytes(port)).status, 503); + deepStrictEqual(await within(late.promise), "resolved"); + deepStrictEqual(traps, []); + }, + ); + }); + + test("once the response has ended, or by a work that may no longer mutate it: a Node.js `Readable` is released by the methods of Node.js", async () => { + const responses = [ + (): BindenResponse => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response.end(); + return response; + }, + (): BindenResponse => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response[kSeal](() => false); + return response; + }, + ]; + const read: string[] = []; + const traps: string[] = []; + for (const respond of responses) { + // Its own methods, which never run (nor their getters) + const source = new Readable({ + read(): void { + // Nothing to read + }, + }); + for (const name of [ + "on", + "off", + "removeListener", + "pause", + "resume", + "destroy", + ]) { + Object.defineProperty(source, name, { + get(): never { + read.push(name); + throw new Error(`The source has been read (${name})`); + }, + }); + } + // A `Proxy` anywhere in its chain of prototypes: not touched + const wrapped = new Readable(); + const proxied = new Readable(); + Object.setPrototypeOf(proxied, untouchable(traps, Readable.prototype)); + const behind: unknown = Object.create( + untouchable(traps, Readable.prototype), + ); + // Closed already: nothing to release, nor any guard to leave on it + const closed = new Readable(); + closed.destroy(); + await once(closed, "close"); + for (const sent of [ + source, + untouchable(traps, wrapped), + proxied, + behind, + closed, + ]) { + const outcome = await respond() + // @ts-expect-error -- not always data that `send()` takes + .send(sent) + .then( + () => null, + (error: unknown) => error, + ); + deepStrictEqual(outcome, null); + } + // By `Readable.prototype.destroy()`, without being read + deepStrictEqual([source.destroyed, source.readableFlowing], [true, null]); + ok(!wrapped.destroyed); + ok(!(Reflect.get(proxied, "_readableState") as Readable).destroyed); + deepStrictEqual( + [closed.listenerCount("error"), closed.listenerCount("close")], + [0, 0], + ); + } + deepStrictEqual([read, traps], [[], []]); + }); + + test("once the response has ended: a Node.js `Readable` whose release fails is logged, and its send resolves", async () => { + const captured = capture(); + const outcomes = Promise.withResolvers(); + const app = new Binden({ logger: captured.logger }).use( + async (context: Context) => { + context.response.end("ended"); + const sends: unknown[] = []; + for (const sent of [ + // Its `_destroy()` throws: Node.js emits the error, which its guard + // logs + new Readable({ + read(): void { + // Nothing to read + }, + destroy(): void { + throw new Error("Its destroy has failed"); + }, + }), + // Not constructed as a `Readable`: its release throws at once + Object.create(Readable.prototype) as Readable, + ]) { + sends.push( + await context.response.send(sent).then( + () => "resolved", + (error: unknown) => error, + ), + ); + } + outcomes.resolve(sends); + }, + ); + const listening = await listen(app); + try { + const { status, body } = await fetchBytes(listening.port); + deepStrictEqual([status, body.toString()], [200, "ended"]); + deepStrictEqual(await within(outcomes.promise), ["resolved", "resolved"]); + await captured.waitFor(releasedFailure); + await captured.waitFor("The release of a stream has failed"); + } finally { + await listening.close(); + } + }); + + test("its errors once it has closed or been cancelled do not crash the process", () => { + // A `PassThrough` of `readable-stream` 2 destroyed with an error (its + // `end` and its `close`, then the error, which is replied), a source that + // emits `close` then an error, and the reason of the deadline (also + // emitted once a destroy completes later): the guards of the sources + // whose state cannot tell that they have closed stay + deepStrictEqual(fixture("stream-like-errors.ts"), { + status: 0, + stdout: "500 500 503 503\n1 1 1 0\n", + stderr: "", + }); + }); + + test("the late errors of its destroy, without a listener of the application, are logged and do not crash the process", () => { + // Released to `HEAD`, with 204, 304 and 103, its destroy failing at once; + // ignored once the response has ended (not destroyed); an error after + // its close (whose guard stays: its state cannot tell that it has + // closed); and destroys that fail 2 s later (released to `HEAD`, and + // cancelled at the deadline) + deepStrictEqual(fixture("stream-like-release.ts"), { + status: 0, + stdout: + "200 204 304 500 200 200 200 503\n" + + `${[ + ...Array.from({ length: 4 }, () => "Failed to close after 0 ms"), + "Failed once closed", + "Failed to close after 2000 ms", + "Failed to close after 2000 ms", + ].join(", ")}\n` + + "0 0 0 0 0 1 0 0\n", + stderr: "", + }); + }); + + test("the late errors of a source without destroy(), or destroyed by the application, without a listener of the application, are logged and do not crash the process", () => { + // Its pause fails on the next tick (cancelled at the deadline, and + // released with 204); destroyed before the send (`readable-stream` 2 and + // 4 failing 20 ms later, a `PassThrough` of 2 on the next tick), or once + // sent by an `end` (failing 20 ms later): the guards of those that never + // close, or whose state cannot tell it (`readable-stream` 2), stay. The + // error of `readable-stream` 4 (its `errored`) rejects its send, and is + // not logged + deepStrictEqual(fixture("stream-like-destroyed.ts"), { + status: 0, + stdout: + "503 204 500 500 500 500\n" + + "deadline resolved ERR_STREAM_PREMATURE_CLOSE ERR_STREAM_PREMATURE_CLOSE " + + "Failed before the send (/v4) ERR_STREAM_PREMATURE_CLOSE\n" + + "Failed after its end, Failed after its pause, Failed after its pause, " + + "Failed before the send (/v2), Failed in the same turn\n" + + "1 1 1 1 0 1\n", + stderr: "", + }); + }); + + test("the late error of its destroy by autoDestroy once it has ended (before the send, or while sent), without a listener of the application, is logged and does not crash the process", () => { + // `readable-stream` 4, whose destroy fails 20 ms after its end: its + // guard listens before the listeners of the send are removed, and is + // removed once it has closed + deepStrictEqual(fixture("stream-like-ended.ts"), { + status: 0, + stdout: + "200 200\nresolved resolved\n" + + "Failed after its end (/active), Failed after its end (/ended)\n" + + "0 0\n", + stderr: "", + }); + }); + + test("cancelled, whose destroy() emits nothing, does not keep its response: only its guard stays", () => { + // 400 sources: no listener of their sends (`data`, `end`), and a guard + // each (`error`, `close`) + deepStrictEqual(fixture("stream-like-retention.ts", "--expose-gc"), { + status: 0, + stdout: "collected 0 0 400 400\n", + stderr: "", + }); + }); +}); + +suite("guard() of a source", () => { + test("by the methods of Node.js (emitterMethods): the own methods of a Readable are never read, the error of its destroy is reported, and it is removed on the turn after its close", async () => { + const reports: unknown[] = []; + const source = new Readable({ + read(): void { + // Nothing to read + }, + destroy(_error, callback): void { + callback(new Error("Failed to close")); + }, + }); + const read: string[] = []; + for (const name of ["on", "removeListener", "off", "destroy"]) { + Object.defineProperty(source, name, { + get(): never { + read.push(name); + throw new Error(`${name} has been read`); + }, + }); + } + guard(source, emitterMethods, (error) => reports.push(error)); + guard(source, emitterMethods, (error) => reports.push(error)); + Readable.prototype.destroy.call(source); + await nextTurn(); + await nextTurn(); + deepStrictEqual( + [ + reports.map((error) => (error as Error).message), + read, + source.listenerCount("error"), + source.listenerCount("close"), + ], + [["Failed to close"], [], 0, 0], + ); + }); +}); + +suite("send() of another object", () => { + /** Objects that are neither bytes, a `Readable` nor a stream-like source, whose methods record their calls */ + const objects = (calls: string[]): Record => { + const method = + (name: string): (() => void) => + (): void => { + calls.push(name); + }; + return { + "a plain object": {}, + "an object with only on() and pipe()": { + on: method("on"), + pipe: method("pipe"), + }, + "an object without removeListener() nor off()": { + on: method("on"), + pause: method("pause"), + resume: method("resume"), + }, + "an object without on()": { + pause: method("pause"), + resume: method("resume"), + removeListener: method("removeListener"), + }, + "an object without pause()": { + on: method("on"), + resume: method("resume"), + removeListener: method("removeListener"), + }, + "an object without resume()": { + on: method("on"), + pause: method("pause"), + off: method("off"), + }, + "a function": Object.assign(method("call"), { + on: method("on"), + }), + "a DataView": new DataView(new ArrayBuffer(2)), + URLSearchParams: new URLSearchParams("a=b"), + }; + }; + + test("rejects with a TypeError before anything is changed, without calling it", async () => { + const calls: string[] = []; + for (const [name, value] of [ + ...Object.entries(objects(calls)), + ["null", null], + ] as const) { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response.setHeader("Content-Length", 3); + // @ts-expect-error -- not data that `send()` takes + const sent = response.send(value); + await rejects( + sent, + (error: unknown) => + error instanceof TypeError && + error.message.startsWith("The data is not a string"), + name, + ); + deepStrictEqual( + [ + response.started, + response.headersSent, + response.getHeader("Content-Length"), + ], + [false, false, 3], + name, + ); + } + deepStrictEqual(calls, []); + }); + + test("is a 500 through the error handler", async () => { + await withApp( + {}, + async (context: Context) => { + // @ts-expect-error -- not data that `send()` takes + await context.send({ toString: () => "text" }); + }, + async ({ port }) => { + const received = await fetchBytes(port); + deepStrictEqual([received.status, received.body.toString()], [500, ""]); + }, + ); + }); + + test("a Uint8Array is sent as its bytes", async () => { + const bytes = new Uint8Array([0x61, 0x62, 0x63, 0x64]).subarray(1, 3); + await withApp( + {}, + async (context: Context) => { + await context.send(bytes); + }, + async ({ port }) => { + const received = await fetchBytes(port); + deepStrictEqual( + [ + received.status, + received.body.toString(), + received.headers["content-length"], + ], + [200, "bc", "2"], + ); + }, + ); + }); + + test("is ignored once the response is sealed, as any send", async () => { + const response = new BindenResponse(new IncomingMessage(new Socket())); + response.end(); + // Resolves, without a `TypeError` + // @ts-expect-error -- not data that `send()` takes + await response.send({}); + }); +}); diff --git a/packages/binden/test/throwing-stream-like-logs.test.ts b/packages/binden/test/throwing-stream-like-logs.test.ts new file mode 100644 index 00000000..b55b5590 --- /dev/null +++ b/packages/binden/test/throwing-stream-like-logs.test.ts @@ -0,0 +1,162 @@ +import { + type IStreamLikeLogs, + type IStreamLikeMethods, + readStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import type { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** A stream-like source of its own, which records the arguments of each destroy */ +class Source extends EventEmitter { + public readonly destroys: unknown[][] = []; + + public pause(): this { + return this; + } + + public resume(): this { + return this; + } + + public destroy(...args: unknown[]): this { + this.destroys.push(args); + return this; + } +} + +/** What a logger of the application that throws throws */ +const failure = new Error("The logger has failed"); + +/** A log that throws, as a logger of the application whose own `debug()` throws */ +function thrown(): never { + throw failure; +} + +/** Logs (see `IStreamLikeLogs`) that throw */ +const throwing: IStreamLikeLogs = { released: thrown, removed: thrown }; + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +/** The errors that a readable emits (it emits them on the next tick) */ +function errorsOf(readable: Readable): unknown[] { + const errors: unknown[] = []; + readable.on("error", (error: unknown) => { + errors.push(error); + }); + return errors; +} + +suite( + "A readable of a stream-like source whose logs throw (e.g. a logger of the application whose own `debug()` throws)", + () => { + test("cancelled, whose removal of a listener throws: its other listeners are removed, and it is destroyed with the reason, the readable without an error (R2)", async () => { + const source = new Source(); + Object.assign(source, { + removeListener(event: string, listener: () => void): Source { + EventEmitter.prototype.removeListener.call(source, event, listener); + if (event === "data") { + throw new Error("No removal"); + } + return source; + }, + }); + const reason = new Error("Cancelled"); + const readable = readStreamLike( + source, + methodsOf(source), + () => reason, + () => null, + () => null, + throwing, + ); + const errors = errorsOf(readable); + readable.destroy(); + await new Promise(setImmediate); + deepStrictEqual( + [ + // Its guard listens (to its `error` and its `close`) + ["data", "end", "close", "error"].map((event) => + source.listenerCount(event), + ), + source.destroys, + readable.errored, + errors, + ], + [[0, 0, 1, 1], [[reason]], null, []], + ); + }); + + test("cancelled, whose `destroy()` throws: the readable is destroyed without an error (R2)", async () => { + const source = new Source(); + Object.assign(source, { + destroy(): never { + throw new Error("No destroy"); + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + () => null, + () => null, + throwing, + ); + const errors = errorsOf(readable); + readable.destroy(); + await new Promise(setImmediate); + deepStrictEqual([readable.errored, errors], [null, []]); + }); + + test("that refuses every listener of its `error`, left untouched, whose notice of it (`unreleased()`) throws: the readable is destroyed with what the source has thrown (R2)", async () => { + const refused = new Error("No listener of error"); + const source = new Source(); + Object.assign(source, { + on(event: string, listener: () => void): Source { + if (event === "error") { + throw refused; + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + }); + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + () => null, + thrown, + { released: () => null, removed: () => null }, + ); + const errors = errorsOf(readable); + await new Promise(setImmediate); + deepStrictEqual( + [readable.errored, errors, source.destroys], + [refused, [refused], []], + ); + }); + + test("ended, whose deferred removal of the listener of `error` throws, and so does its log: does not crash the process (R2)", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "throwing-stream-like-logs.ts"), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual( + { status, stdout, stderr }, + { status: 0, stdout: "ended true\n", stderr: "" }, + ); + }); + }, +); diff --git a/packages/binden/test/thrown-decisions.test.ts b/packages/binden/test/thrown-decisions.test.ts new file mode 100644 index 00000000..585bc000 --- /dev/null +++ b/packages/binden/test/thrown-decisions.test.ts @@ -0,0 +1,170 @@ +import { Binden, type Context, releasedFailure } from "../index.js"; +import { capture, ioTurn, listen, pollUntil, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** + * Sends `GET /first` (a source that fails its send with an error), then + * `HEAD /second`: a `Readable` released to `HEAD` (under its guard), whose + * destroy fails 10 ms later with that same error. The statuses (`0` for a + * response that is closed before it is complete), the message + * of that error, and the late errors logged (once the second source has + * closed) + */ +async function run( + first: (context: Context) => Readable, +): Promise<[number[], string, unknown[]]> { + const captured = capture(); + const failures: unknown[] = []; + let second: Readable | null = null; + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + if (context.url.pathname === "/second") { + const [failure] = failures; + second = new Readable({ + read(): void { + // Never read: released to `HEAD` + }, + destroy(_error, callback): void { + setTimeout(callback, 10, failure); + }, + }); + await context.send(second); + return; + } + await context.send(first(context)).catch((error: unknown) => { + failures.push(error); + }); + await context.response.status(500).send("failed"); + }); + const listening = await listen(app); + try { + const statuses = [ + // A response that has written something is closed once its send fails + ( + await send(listening.port, { path: "/first" }).catch(() => ({ + status: 0, + })) + ).status, + (await send(listening.port, { method: "HEAD", path: "/second" })).status, + ]; + await pollUntil(() => second?.closed === true); + await ioTurn(); + const late = captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { message?: unknown }).message); + return [statuses, String((failures[0] as Error).message), late]; + } finally { + await listening.close(); + } +} + +suite( + "An error that fails the send of a Node.js `Readable` that binden finds itself, then a late error of another source", + () => { + test("thrown by code of the source (an object of the application, here by its own `resume()`): decided for that source only, logged once for the other one (R1 F2)", async () => { + const failure = new Error("One error of the application"); + const [statuses, message, late] = await run(() => { + const source = new Readable({ + read(): void { + // Never read + }, + }); + // The one that `on("data")` of Node.js calls + Object.assign(source, { + resume(): never { + throw failure; + }, + }); + return source; + }); + deepStrictEqual( + [statuses, message, late], + [[500, 200], failure.message, [failure.message]], + ); + }); + + test("thrown by its own `pause()` once the response is full (its socket corked): decided for that source only, logged once for the other one (R1 F2)", async () => { + const failure = new Error("One error of the application"); + const [statuses, message, late] = await run((context) => { + // Every write is buffered: the response is full at once + context.response.cork(); + let pushed = false; + const source = new Readable({ + read(): void { + if (!pushed) { + pushed = true; + this.push(Buffer.alloc(64 * 1024)); + } + }, + }); + Object.assign(source, { + pause(): never { + throw failure; + }, + }); + return source; + }); + deepStrictEqual( + [statuses, message, late], + [[0, 200], failure.message, [failure.message]], + ); + }); + + test("thrown by its own `resume()` once the response drains (its socket corked, then uncorked): decided for that source only, logged once for the other one (R1 F2)", async () => { + const failure = new Error("One error of the application"); + const [statuses, message, late] = await run((context) => { + // The response is full at once, and drains once uncorked + context.response.cork(); + setTimeout(() => { + context.response.uncork(); + }, 10); + let pushed = false; + let paused = false; + const source = new Readable({ + read(): void { + if (!pushed) { + pushed = true; + this.push(Buffer.alloc(64 * 1024)); + } + }, + }); + Object.assign(source, { + pause(): Readable { + paused = true; + return Readable.prototype.pause.call(source); + }, + resume(): Readable { + if (paused) { + throw failure; + } + return Readable.prototype.resume.call(source); + }, + }); + return source; + }); + deepStrictEqual( + [statuses, message, late], + [[0, 200], failure.message, [failure.message]], + ); + }); + + test("of binden (a body longer than its `Content-Length`): decided for every source, never logged", async () => { + const [statuses, message, late] = await run((context) => { + context.response.setHeader("Content-Length", 1); + return Readable.from(["too long"]); + }); + deepStrictEqual( + [statuses, message, late], + [ + [500, 200], + "The body (8 bytes) does not match its Content-Length (1 bytes)", + [], + ], + ); + }); + }, +); diff --git a/packages/binden/test/types.test.ts b/packages/binden/test/types.test.ts new file mode 100644 index 00000000..2c309ab0 --- /dev/null +++ b/packages/binden/test/types.test.ts @@ -0,0 +1,124 @@ +// 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 { + Binden, + type Context, + type IBindenOptions, + type IRouteParams, + Middleware, + Router, + Stack, +} from "../index.js"; +import { ok } from "node:assert/strict"; +import { test } from "node:test"; + +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" }) })); + // @ts-expect-error -- a promise is not a state + is(new Binden({ state: (): Promise => Promise.resolve(state()) })); + // @ts-expect-error -- nor is a thenable + is(new Binden({ state: (): { then(): void } => ({ then: Date.now }) })); + // The state of a factory is inferred + new Binden({ state: (): { count: number } => ({ count: 0 }) }).use( + (context): void => { + is(context.state.count); + }, + ); + // 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); +}); diff --git a/packages/binden/test/unguarded-sources.test.ts b/packages/binden/test/unguarded-sources.test.ts new file mode 100644 index 00000000..b24e9ac6 --- /dev/null +++ b/packages/binden/test/unguarded-sources.test.ts @@ -0,0 +1,146 @@ +import { + type IStreamLikeMethods, + readStreamLike, + releaseStreamLike, + streamLikeOf, +} from "../src/stream-like.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; +import { ioTurn } from "./helpers.js"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, without a handler + * of the process: a crash (e.g. an unhandled `error` event) is its exit code + * and output, not a failure of the test runner + */ +function fixture(name: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +/** A stream-like source whose calls are recorded */ +class Recorded extends EventEmitter { + public readonly calls: string[] = []; + + public pause(): this { + this.calls.push("pause"); + return this; + } + + public resume(): this { + this.calls.push("resume"); + return this; + } + + public destroy(): this { + this.calls.push("destroy"); + return this; + } +} + +/** + * A source that refuses every listener of `error`: its own `on()` throws, + * after it has added the first one (`added`), or before + */ +function refusing(added: boolean): Recorded { + const source = new Recorded(); + let errors = 0; + return Object.assign(source, { + on(event: string, listener: () => void): Recorded { + if (event === "error") { + errors += 1; + if (added && errors === 1) { + EventEmitter.prototype.on.call(source, event, listener); + } + throw new Error(`No listener of error #${errors}`); + } + EventEmitter.prototype.on.call(source, event, listener); + return source; + }, + }); +} + +/** The methods of a stream-like source (see `streamLikeOf()`) */ +function methodsOf(source: object): IStreamLikeMethods { + return streamLikeOf(source) as IStreamLikeMethods; +} + +suite("A stream-like source without a listener of `error` in place", () => { + for (const added of [false, true]) { + test(`sent, that refuses every one${added ? " (its on() has added the first, then thrown)" : ""}: left untouched, neither paused nor destroyed, and told`, async () => { + const source = refusing(added); + const told: string[] = []; + const reports: unknown[] = []; + const readable = readStreamLike( + source, + methodsOf(source), + () => new Error("Cancelled"), + (error) => reports.push((error as Error).message), + () => told.push("unreleased"), + ); + const failure = await new Promise((resolve) => { + readable.once("error", resolve); + }); + await ioTurn(); + deepStrictEqual( + [ + (failure as Error).message, + source.calls, + told, + reports, + source.eventNames(), + ], + [ + "No listener of error #1", + [], + ["unreleased"], + ["No listener of error #2"], + [], + ], + ); + }); + } + + test("released, that refuses its guard: left untouched, without destroy() too, and told", () => { + const told: string[] = []; + for (const withDestroy of [true, false]) { + const source = refusing(false); + if (!withDestroy) { + Object.assign(source, { destroy: null }); + } + releaseStreamLike( + source, + methodsOf(source), + () => null, + () => told.push(`unreleased (${String(withDestroy)})`), + ); + deepStrictEqual(source.calls, []); + } + deepStrictEqual(told, ["unreleased (true)", "unreleased (false)"]); + }); + + test("whose pause() and destroy() would fail asynchronously: sent or released (to HEAD, with 204, or for 103), it is never paused nor destroyed, and the process does not crash", () => { + deepStrictEqual(fixture("unguarded-sources.ts"), { + status: 0, + stdout: + "GET / 500 rejected:No listener of error\n" + + "HEAD / 200 resolved\n" + + "GET /204 204 resolved\n" + + "GET /103 500 rejected:Status code 103 is informational (use writeEarlyHints())\n" + + "calls=none left=4\n", + stderr: "", + }); + }); +}); diff --git a/packages/binden/test/unpushable-chunks.test.ts b/packages/binden/test/unpushable-chunks.test.ts new file mode 100644 index 00000000..2809d2c1 --- /dev/null +++ b/packages/binden/test/unpushable-chunks.test.ts @@ -0,0 +1,62 @@ +import { Binden, type Context } from "../index.js"; +import { capture, listen, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { deepStrictEqual } from "node:assert/strict"; + +suite( + "A stream-like source that emits a chunk that the readable of its send cannot take", + () => { + test("(a view of an `ArrayBuffer` that has been detached): rejects with what its `push()` throws, never thrown into the `data` of the source, which is destroyed with it (R2)", async () => { + const { logger } = capture(); + const emits: string[] = []; + const outcomes: string[] = []; + const destroys: unknown[] = []; + const app = new Binden({ logger }).use(async (context: Context) => { + const source = Object.assign(new EventEmitter(), { + pause(): void { + // Never full + }, + resume(): void { + // Emits by the test + }, + destroy(error?: Error): void { + destroys.push(error); + }, + }); + setImmediate(() => { + const chunk = new Uint8Array(4); + chunk.buffer.transfer(); + try { + source.emit("data", chunk); + emits.push("returned"); + } catch (error: unknown) { + emits.push(`threw ${(error as Error).name}`); + } + source.emit("end"); + }); + await context.send(source).catch((error: unknown) => { + const { name } = error as Error; + outcomes.push(name); + return context.response.status(500).send("failed"); + }); + }); + const listening = await listen(app); + try { + const { status, body } = await send(listening.port); + deepStrictEqual( + [ + status, + body, + emits, + outcomes, + destroys.map((error) => typeof error), + ], + [500, "failed", ["returned"], ["TypeError"], ["object"]], + ); + } finally { + await listening.close(); + } + }); + }, +); diff --git a/packages/binden/test/unreadable-sends.test.ts b/packages/binden/test/unreadable-sends.test.ts new file mode 100644 index 00000000..d0263fa0 --- /dev/null +++ b/packages/binden/test/unreadable-sends.test.ts @@ -0,0 +1,289 @@ +import { + Binden, + BindenResponse, + type Context, + type IRepresentation, + type IRepresentationNegotiator, + kNegotiator, + releasedFailure, +} from "../index.js"; +import { Duplex, Readable } from "node:stream"; +import { type ICaptured, capture, listen, within } from "./helpers.js"; +import { converse, get } from "./connection.js"; +import { kSeal, owners } from "../src/seal.js"; +import { suite, test } from "node:test"; +import { IncomingMessage } from "node:http"; +import type { Socket } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; + +/** How a source cannot be read when it is sent */ +type IUnreadable = "destroyed" | "errored"; + +/** + * A source that nothing can be read of: destroyed, or errored without being + * destroyed (a chunk that is not bytes, which `autoDestroy: false` keeps), + * which records its release (its `_destroy()`, e.g. of a file that it closes) + */ +function unreadable(kind: IUnreadable): { + source: Readable; + /** Whether its `_destroy()` has run (it holds its resources until then) */ + released(): boolean; +} { + let released = false; + const source = new Readable({ + autoDestroy: false, + read(): void { + // Never read + }, + destroy(error, callback): void { + released = true; + callback(error); + }, + }); + source.on("error", () => { + // Its error is `errored` + }); + if (kind === "destroyed") { + source.destroy(); + released = false; + } else { + source.push({}); + } + return { source, released: () => released }; +} + +/** A negotiator of the body as it is, which counts how often its choice is forgotten */ +class Negotiator implements IRepresentationNegotiator { + public forgotten = 0; + + // eslint-disable-next-line class-methods-use-this -- the body as it is + public choose(): IRepresentation { + return { coding: null, status: null }; + } + + public forget(): void { + this.forgotten += 1; + } +} + +/** A response to `GET` on a connection that records nothing */ +function detached(): { response: BindenResponse; close(): void } { + const wire = new Duplex({ + read(): void { + // The client sends nothing more + }, + write(_chunk: Buffer, _encoding, done): void { + done(); + }, + }); + const request = new IncomingMessage(wire as unknown as Socket); + Object.assign(request, { + method: "GET", + headers: {}, + httpVersionMajor: 1, + httpVersionMinor: 1, + }); + const response = new BindenResponse(request); + response.assignSocket(wire as unknown as Socket); + return { + response, + close: (): void => { + wire.destroy(); + }, + }; +} + +/** How a send has settled: `resolved`, or the code (else the message) of what it has rejected with */ +function outcomeOf(promise: Promise): Promise { + return promise.then( + () => "resolved", + (error: unknown) => { + const { code, message } = error as { code?: unknown; message?: unknown }; + return code ?? message; + }, + ); +} + +/** + * The first response of what has been received: its framing (`chunked`, or + * its `Content-Length`), its body, and whether exactly the reply of `/next` + * follows it + */ +function first(received: string): [string, string, boolean] { + const end = received.indexOf("\r\n\r\n"); + const head = received.slice(0, end).toLowerCase(); + const rest = received.slice(end + 4); + const chunked = head.includes("\r\ntransfer-encoding: chunked"); + // Where its body ends: after its last chunk (without trailers), or its length + const length = chunked + ? rest.indexOf("0\r\n\r\n") + 5 + : Number(/\r\ncontent-length: (?\d+)/u.exec(head)?.groups?.length); + const framing = chunked ? "chunked" : `Content-Length: ${String(length)}`; + const next = rest.slice(length); + return [ + framing, + rest.slice(0, length), + next.startsWith("HTTP/1.1 200 OK\r\n") && next.endsWith("\r\n\r\nnext"), + ]; +} + +/** + * Runs `scenario` as the middleware of `/` (`/next` replies `next`), with + * `/` and `/next` pipelined on one connection: what the client receives, how + * the scenario has settled, and the logger + */ +async function run( + scenario: (context: Context) => Promise, +): Promise<[string, unknown, ICaptured]> { + const captured = capture(); + const outcome = Promise.withResolvers(); + const app = new Binden({ + logger: captured.logger, + log_error_details: true, + }).use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.text("next"); + } else { + outcome.resolve(await scenario(context)); + } + }); + const listening = await listen(app); + try { + const { received } = await converse( + listening.port, + `${get("/")}${get("/next")}`, + { complete: (text) => text.endsWith("\r\n\r\nnext") }, + ); + return [received, await within(outcome.promise), captured]; + } finally { + await listening.close(); + } +} + +suite("A send of a Readable that nothing can be read of", () => { + for (const kind of ["destroyed", "errored"] as const) { + const error = + kind === "destroyed" + ? "ERR_STREAM_PREMATURE_CLOSE" + : "ERR_INVALID_ARG_TYPE"; + + test(`(${kind}) rejects before the headers are committed without the framing of its body: the work that ends the response sends an empty body, and the next response is read as such (R19-H2)`, async () => { + const [received, outcome] = await run(async (context) => { + const { response } = context; + // The length of the body that it was to send + response.setHeader("Content-Length", 3); + const sent = await outcomeOf(context.send(unreadable(kind).source)); + const framed = response.hasHeader("Content-Length"); + response.end(); + return [sent, framed]; + }); + // An empty body (its last chunk), then the next response + deepStrictEqual( + [outcome, first(received)], + [ + [error, false], + ["chunked", "0\r\n\r\n", true], + ], + ); + }); + + test(`(${kind}) rejects without the representation chosen for its body`, async () => { + const connection = detached(); + const { response } = connection; + try { + const negotiator = new Negotiator(); + response[kNegotiator](negotiator); + response.setHeader("Content-Length", 3); + const sent = await outcomeOf(response.send(unreadable(kind).source)); + deepStrictEqual( + [sent, negotiator.forgotten, response.hasHeader("Content-Length")], + [error, 1, false], + ); + } finally { + connection.close(); + } + }); + + test(`(${kind}) whose code ends the work while it is checked: rejects without the framing of its body, removed with the rights of the framework`, async () => { + const connection = detached(); + const { response } = connection; + try { + const work = {}; + let revoked = false; + response[kSeal]((owner) => owner !== work || !revoked); + const negotiator = new Negotiator(); + response[kNegotiator](negotiator); + const { source } = unreadable(kind); + const { destroyed } = source; + // A getter of its own that the checks of the send read + Object.defineProperty(source, "destroyed", { + get(): boolean { + revoked = true; + return destroyed; + }, + }); + const sent = await owners.run(work, () => { + response.setHeader("Content-Length", 3); + return outcomeOf(response.send(source)); + }); + // Its work, which may no longer mutate the response, forgets nothing + deepStrictEqual( + [sent, negotiator.forgotten, response.hasHeader("Content-Length")], + [error, 0, false], + ); + } finally { + connection.close(); + } + }); + } + + test("errored without being destroyed: rejects at once with its error, then is released (its resources freed)", async () => { + const connection = detached(); + const { response } = connection; + try { + const unread = unreadable("errored"); + const { source } = unread; + const sent = await outcomeOf(response.send(source)); + deepStrictEqual( + [sent, source.destroyed, unread.released()], + ["ERR_INVALID_ARG_TYPE", true, true], + ); + } finally { + connection.close(); + } + }); + + test("errored without being destroyed, whose code ends the response while it is checked: the send settles as the response, its error is logged once, and it is released", async () => { + const unread = unreadable("errored"); + const { source } = unread; + const { errored } = source; + const [received, outcome, captured] = await run(async (context) => { + // A getter of its own that ends the response once `send()` reads it + Object.defineProperty(source, "errored", { + get(): Error | null { + context.response.end("ended"); + return errored; + }, + }); + return outcomeOf(context.send(source)); + }); + deepStrictEqual( + [ + received.match(/\r\n\r\n(?:ended|next)/gu), + outcome, + captured.records + .filter(({ msg }) => msg === releasedFailure) + .map(({ failure }) => (failure as { code?: unknown }).code), + source.destroyed, + unread.released(), + ], + [ + ["\r\n\r\nended", "\r\n\r\nnext"], + "resolved", + ["ERR_INVALID_ARG_TYPE"], + true, + true, + ], + ); + }); +}); diff --git a/packages/binden/test/unreleased-readables.test.ts b/packages/binden/test/unreleased-readables.test.ts new file mode 100644 index 00000000..38d51b89 --- /dev/null +++ b/packages/binden/test/unreleased-readables.test.ts @@ -0,0 +1,61 @@ +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own, without a handler + * of the process: a crash (e.g. an unhandled `error` event) is its exit code + * and output, not a failure of the test runner + */ +function fixture(name: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", name), + ], + { encoding: "utf8", timeout: 40_000 }, + ); + return { status, stdout, stderr }; +} + +suite("A Node.js `Readable` that refuses every listener of `error`", () => { + test("is left untouched, never paused nor resumed (a listener of its `pause` or its `resume` that fails it later would crash the process), and that is logged; one paused by its send fails later harmlessly", () => { + // Sent (its send rejects with the refusal), paused by the application + // before, released to `HEAD` or for an informational status, or ignored + // once the response has ended: never paused nor resumed by binden (its + // send adds no other listener before the one of its `error`), nor + // destroyed, and nothing of the send is left on it + const line = (path: string, reply: string, flowing: string): string => + `${path} ${reply} pauses=0 resumes=0 flowing=${flowing} destroyed=false listeners=0/0/0/0`; + deepStrictEqual(fixture("unreleased-readables.ts"), { + status: 0, + stdout: + `${[ + line("/send", "500 rejected:No listener of error (/send)", "null"), + line( + "/paused", + "500 rejected:No listener of error (/paused)", + "false", + ), + line("/head", "200 resolved", "null"), + line("/ignored", "200 resolved", "null"), + line( + "/informational", + "500 rejected:Status code 103 is informational (use writeEarlyHints())", + "null", + ), + ].join("\n")}\n` + + // Once per source + "left=5\n" + + // Paused by its send only, while the send listens: the listener of + // the send, which its guard could not replace, logs the error of its + // pause that comes once the send has been cut, once + "/listened 200 rejected:The request has been aborted (deadline) pauses=1 destroyed=true listeners=0/0/1/0 late=1\n", + stderr: "", + }); + }); +}); diff --git a/packages/binden/test/unsent-values.test.ts b/packages/binden/test/unsent-values.test.ts new file mode 100644 index 00000000..0878ac06 --- /dev/null +++ b/packages/binden/test/unsent-values.test.ts @@ -0,0 +1,340 @@ +import { type AddressInfo, Socket } from "node:net"; +import { + Binden, + BindenResponse, + type Context, + type IResponseOptions, + kNegotiator, + validateResponseOptions, +} from "../index.js"; +import { IncomingMessage, type ServerResponse, createServer } from "node:http"; +import { deepStrictEqual, ok, rejects } from "node:assert/strict"; +import { listen, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { converse } from "./connection.js"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; + +/** 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: string): string { + return `"${createHash("sha256").update(body).digest("base64url")}"`; +} + +/** + * A response without a connection to an HTTP/1.1 request (nothing is sent), + * with the dynamic `ETag` or not + */ +function detached( + method: string, + headers: Record, + etag: boolean, +): BindenResponse { + const request = new IncomingMessage(new Socket()); + request.httpVersionMajor = 1; + request.httpVersionMinor = 1; + request.method = method; + request.headers = headers; + return etag ? new EtagResponse(request) : new BindenResponse(request); +} + +/** The value sends, by name (`hello` as a body) */ +const sends: Record Promise> = { + "send() of a string": async (response) => response.send("hello"), + "send() of bytes": async (response) => response.send(Buffer.from("hello")), + "send() of a number": async (response) => response.send(5), + "send() without a body": async (response) => response.send(), + "json()": async (response) => response.json({ a: 1 }), + "text()": async (response) => response.text("hello"), + "html()": async (response) => response.html("hello"), + "form()": async (response) => response.form(new URLSearchParams("a=1")), +}; + +/** What Node.js refuses as it commits the head, by name, and its code */ +const refusals: Record void, string]> = { + "a reason phrase": [ + (response): void => { + response.statusMessage = "bad\nx"; + }, + "ERR_INVALID_CHAR", + ], + "a status out of range": [ + (response): void => { + response.statusCode = 1000; + }, + "ERR_HTTP_INVALID_STATUS_CODE", + ], + "a status out of range once truncated": [ + (response): void => { + response.statusCode = 99.9; + }, + "ERR_HTTP_INVALID_STATUS_CODE", + ], + "a Trailer of a body that is not chunked": [ + (response): void => { + response.setHeader("Trailer", "X-T"); + }, + "ERR_HTTP_TRAILER_INVALID", + ], +}; + +/** The state of a response that a reply that follows a send inherits */ +function stateOf(response: ServerResponse): unknown[] { + return [ + response.statusCode, + response.statusMessage, + response.getHeaders(), + response.headersSent, + Reflect.get(response, "_removedContLen"), + Reflect.get(response, "_removedTE"), + ]; +} + +suite( + "A value send whose head Node.js refuses as it commits it, while the head is not sent (H30-2)", + () => { + test("rejects, and leaves the response as it was before it: the reply that follows inherits neither its Content-Type, its ETag, its status nor the framing of its body", async () => { + // To `GET` and `HEAD`, with the dynamic `ETag` (and the conditions of + // its `304` and `412`) or without, with or without a `Content-Type` set + // before + const requests: [string, Record, boolean][] = [ + ["GET", {}, false], + ["HEAD", {}, false], + ["GET", {}, true], + ["GET", { "if-none-match": etagOf("hello") }, true], + ["GET", { "if-match": '"other"' }, true], + ]; + for (const [name, send] of Object.entries(sends)) { + for (const [refused, [refuse, code]] of Object.entries(refusals)) { + // A `Trailer` is removed by the send of an empty reply (see + // `#frame()`): only the body of `GET` has it + const refusing = requests.filter( + ([method, headers]) => + refused !== "a Trailer of a body that is not chunked" || + (method === "GET" && Object.keys(headers).length === 0), + ); + for (const [method, headers, etag] of refusing) { + for (const type of [null, "text/x-app"]) { + const label = `${name}, ${refused}, ${method} ${JSON.stringify(headers)} ${String(etag)} ${String(type)}`; + const response = detached(method, headers, etag); + response.setHeader("X-App", "1"); + if (type !== null) { + response.setHeader("Content-Type", type); + } + refuse(response); + const before = stateOf(response); + // Refused before anything is sent: it rejects at once + await rejects(within(send(response)), { code }, label); + deepStrictEqual(stateOf(response), before, label); + } + } + } + } + }); + + test("the framing set before it is removed, as by the unwind of a file: the reply that follows frames its own body", async () => { + for (const [header, value] of [ + ["Content-Length", 5], + ["Transfer-Encoding", "chunked"], + ] as const) { + const response = detached("GET", {}, false); + response.setHeader(header, value); + response.statusMessage = "bad\nx"; + await rejects(within(response.send("hello")), { + code: "ERR_INVALID_CHAR", + }); + deepStrictEqual( + [ + response.getHeaderNames(), + response.headersSent, + Reflect.get(response, "_removedContLen"), + Reflect.get(response, "_removedTE"), + ], + [ + [], + false, + header === "Content-Length", + header === "Transfer-Encoding", + ], + header, + ); + } + }); + + test("the representation chosen for its body is forgotten: the reply that follows gets its own", async () => { + let chosen = 0; + let forgotten = 0; + const response = detached("GET", {}, true); + response[kNegotiator]({ + choose: () => { + chosen += 1; + return { coding: null, status: null }; + }, + forget: () => { + forgotten += 1; + }, + }); + response.statusMessage = "bad\nx"; + await rejects(within(response.send("hello")), { + code: "ERR_INVALID_CHAR", + }); + deepStrictEqual([chosen, forgotten], [1, 1]); + }); + + test("once the head is committed (e.g. Node.js throws afterwards, as it corks the socket), the response is left as it is: the send rejects with that error", async () => { + const { promise: outcome, resolve } = Promise.withResolvers(); + const app = new Binden().use(async (context: Context) => { + const { response } = context; + (response.socket as Socket).cork = (): void => { + throw Object.assign(new Error("After the head"), { + code: "E_AFTER_HEAD", + }); + }; + const error = await response.send("hello").then( + () => null, + (thrown: unknown) => thrown, + ); + resolve([ + (error as { code?: unknown } | null)?.code, + response.headersSent, + response.getHeader("Content-Length"), + response.getHeader("Content-Type"), + ]); + }); + const listening = await listen(app); + try { + await converse( + listening.port, + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + deepStrictEqual(await within(outcome), [ + "E_AFTER_HEAD", + true, + 5, + "text/plain", + ]); + } finally { + await listening.close(); + } + }); + + test("in an application: the reply that the work gives after the rejection is framed by its own body, as plain Node.js frames it, and the pipelined request that follows is answered", async () => { + /** + * Refuses the reply of a work (of `/status`, `/reason` or `/trailer`), + * which then replies `err` itself + */ + /** The refusals of the test, by the path of their request */ + const refusedBy = ["status", "reason", "trailer"] as const; + const refuse = (response: ServerResponse, refused: string): void => { + // A refusal of the test chosen by its name: no function is looked up + // by a name from the path + switch (refused) { + case "status": + response.statusCode = 1000; + break; + case "reason": + response.statusMessage = "bad\nx"; + break; + case "trailer": + response.setHeader("Trailer", "X-T"); + break; + default: + throw new Error(`No refusal of the test: ${refused}`); + } + }; + const replied = async ( + response: ServerResponse, + refused: string, + reply: () => unknown, + ): Promise => { + refuse(response, refused); + // Also a synchronous throw + await Promise.resolve() + .then(reply) + .catch(() => null); + Object.assign(response, { + statusCode: 500, + statusMessage: "Internal Server Error", + }); + response.removeHeader("Trailer"); + response.end("err"); + }; + const app = new Binden().use(async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/next") { + await context.send("next"); + return; + } + const [, refused = "", kind = ""] = pathname.split("/"); + const { response } = context; + await replied(response, refused, async () => { + await (kind === "json" + ? response.json({ a: 1 }) + : response.send("hello")); + }); + }); + const plain = createServer((request, response) => { + if (request.url === "/next") { + response.end("next"); + return; + } + const [, refused = ""] = (request.url ?? "").split("/"); + replied(response, refused, () => response.end("hello")).catch( + () => null, + ); + }); + plain.listen(0, "127.0.0.1"); + await once(plain, "listening"); + const listening = await listen(app); + /** What a server answers to a reply and the pipelined request after it */ + const received = async (port: number, path: string): Promise => + ( + await converse( + port, + `GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, + ) + ).received.replaceAll(/Date: [^\r]*\r\n/gu, ""); + try { + for (const refused of refusedBy) { + // Plain Node.js chunks a body that has a `Trailer`, which a value + // send frames by its length: then as a refused reason phrase + const expected = await received( + (plain.address() as AddressInfo).port, + `/${refused === "trailer" ? "reason" : refused}`, + ); + ok( + expected.startsWith( + "HTTP/1.1 500 Internal Server Error\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 3\r\n\r\nerr", + ), + expected, + ); + for (const kind of ["send", "json"]) { + const text = await received(listening.port, `/${refused}/${kind}`); + // The reply of the work as plain Node.js frames it, then the + // reply of the request that follows (of binden's own `send()`) + deepStrictEqual( + text.slice(0, text.indexOf("err") + 3), + expected.slice(0, expected.indexOf("err") + 3), + `${refused} ${kind}`, + ); + ok( + text.endsWith("Connection: close\r\n\r\nnext"), + `${refused} ${kind}: ${text}`, + ); + } + } + } finally { + plain.closeAllConnections(); + plain.close(); + await Promise.all([once(plain, "close"), listening.close()]); + } + }); + }, +); diff --git a/packages/binden/test/unwound-rollbacks.test.ts b/packages/binden/test/unwound-rollbacks.test.ts new file mode 100644 index 00000000..46345f17 --- /dev/null +++ b/packages/binden/test/unwound-rollbacks.test.ts @@ -0,0 +1,431 @@ +import { + Binden, + BindenResponse, + type Context, + type IRepresentation, + type IRepresentationNegotiator, + kNegotiator, +} from "../index.js"; +import { Duplex, PassThrough, Readable } from "node:stream"; +import { after, afterEach, before, mock, suite, test } from "node:test"; +import { capture, listen, never, pollUntil, send, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import fsp, { type FileHandle, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { kCancel, kCancelling } from "../src/seal.js"; +import { IncomingMessage } from "node:http"; +import type { ReadStream } from "node:fs"; +import type { Socket } from "node:net"; +import { join } from "node:path"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; + +/** Long enough never to expire on a loaded machine: it only guards against a hang */ +const deadline = 10_000; + +/** A response to `GET` (with the headers of its request) on a connection that records what it is sent */ +function detached(headers: Record = {}): { + response: BindenResponse; + /** The head of what the connection has been sent, without the `Date` */ + head(): string; + close(): void; +} { + const chunks: Buffer[] = []; + const wire = new Duplex({ + read(): void { + // The client sends nothing more + }, + write(chunk: Buffer, _encoding, done): void { + chunks.push(chunk); + done(); + }, + }); + const request = new IncomingMessage(wire as unknown as Socket); + Object.assign(request, { + method: "GET", + headers, + httpVersionMajor: 1, + httpVersionMinor: 1, + }); + const response = new BindenResponse(request); + response.assignSocket(wire as unknown as Socket); + return { + response, + head: () => + Buffer.concat(chunks) + .toString("latin1") + .replaceAll(/\r\nDate: [^\r]*/gu, ""), + close: (): void => { + wire.destroy(); + }, + }; +} + +/** The settled value of a promise: `null` once resolved, or its error */ +function settled(promise: Promise): Promise { + return within( + promise.then( + () => null, + (error: unknown) => error, + ), + deadline, + ); +} + +/** + * Holds the file of every `sendFile()` of a response before its first chunk: + * its stream is destroyed (its file closed), and a `PassThrough` that sends + * nothing until the test destroys it is sent instead (in the order of the + * sends) + */ +function hold(response: BindenResponse): PassThrough[] { + const held: PassThrough[] = []; + const original = response.send.bind(response) as ( + ...args: unknown[] + ) => Promise; + response.send = (...args: unknown[]): Promise => { + const [data, ...rest] = args; + if (data instanceof Readable) { + data.destroy(); + const stream = new PassThrough(); + held.push(stream); + return original(stream, ...rest); + } + return original(...args); + }; + return held; +} + +/** A negotiator of the body as it is, which counts how often its choice is forgotten */ +class Negotiator implements IRepresentationNegotiator { + public forgotten = 0; + + // eslint-disable-next-line class-methods-use-this -- the body as it is + public choose(): IRepresentation { + return { coding: null, status: null }; + } + + public forget(): void { + this.forgotten += 1; + } +} + +/** The headers that a `sendFile()` of a range sets (its framing among them) */ +const fileHeaders = [ + "accept-ranges", + "content-length", + "content-range", + "content-type", + "etag", + "last-modified", + "x-content-type-options", +]; + +/** The headers of a response, by their names, but `X-Before` (set by the tests before every send) */ +function headersOf(response: BindenResponse): string[] { + return response + .getHeaderNames() + .filter((name) => name !== "x-before") + .sort(); +} + +suite("The cancellation of overlapping sends", () => { + let tmp = ""; + /** The files of the sends, of different types (each one sets its own headers) */ + let files: string[] = []; + + before(async () => { + tmp = await mkdtemp(join(tmpdir(), "binden-unwound-")); + files = [ + join(tmp, "first.pdf"), + join(tmp, "second.txt"), + join(tmp, "third.json"), + ]; + await Promise.all( + files.map((file, index) => writeFile(file, "x".repeat(8 + index))), + ); + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + afterEach(() => { + mock.restoreAll(); + syncBuiltinESMExports(); + }); + + /** + * Starts a `sendFile()` of each file, one after the other, each once the + * file before it is sent (its headers set, which the snapshot of the next + * one holds): their outcomes, and the streams that hold them + */ + async function overlapping( + response: BindenResponse, + count: number, + ): Promise<{ outcomes: Promise[]; held: PassThrough[] }> { + const held = hold(response); + const outcomes: Promise[] = []; + for (const file of files.slice(0, count)) { + outcomes.push(settled(response.sendFile(file))); + const sent = outcomes.length; + await pollUntil(() => held.length >= sent, deadline); + } + return { outcomes, held }; + } + + test("two sendFile() whose files fail once their cancellation is recorded are restored as before the first: the second never reinstates the headers of the first (R19-C2)", async () => { + const connection = detached({ range: "bytes=0-1" }); + const { response } = connection; + try { + response.setHeader("X-Before", "kept"); + const { outcomes, held } = await overlapping(response, 2); + // A range of the second file, with the headers of both + deepStrictEqual(response.statusCode, 206); + const reason = new Error("Cancel both"); + response[kCancelling](reason); + for (const stream of held) { + stream.destroy(new Error("The file has failed")); + } + deepStrictEqual(await Promise.all(outcomes), [reason, reason]); + response[kCancel](reason); + deepStrictEqual([response.statusCode, headersOf(response)], [200, []]); + await within(response.send("FAILED"), deadline); + const head = connection.head(); + ok(head.startsWith("HTTP/1.1 200 OK\r\n"), head); + ok(/^X-Before: kept\r$/mu.test(head), head); + ok(/^Content-Type: text\/plain\r$/mu.test(head), head); + ok( + !/^(?:ETag|Last-Modified|Accept-Ranges|Content-Range|X-Content-Type-Options):/imu.test( + head, + ), + head, + ); + ok(head.endsWith("\r\n\r\nFAILED"), head); + } finally { + connection.close(); + } + }); + + for (const recorded of [false, true]) { + test(`two sendFile() still in progress${recorded ? ", the second settled once their cancellation is recorded," : ""} are restored as before the first: the reply that replaces them has the status and the headers from before both`, async () => { + const connection = detached(); + const { response } = connection; + try { + response.setHeader("X-Before", "kept"); + const { outcomes, held } = await overlapping(response, 2); + const reason = new Error("Cancelled"); + if (recorded) { + response[kCancelling](reason); + held[1]?.destroy(new Error("The second file has failed")); + deepStrictEqual(await outcomes[1], reason); + } + response[kCancel](reason); + deepStrictEqual([response.statusCode, headersOf(response)], [200, []]); + deepStrictEqual(await Promise.all(outcomes), [reason, reason]); + await within(response.status(500).send("FAILED"), deadline); + const head = connection.head(); + ok(head.startsWith("HTTP/1.1 500 Internal Server Error\r\n"), head); + ok(/^X-Before: kept\r$/mu.test(head), head); + ok( + !/^(?:ETag|Last-Modified|Accept-Ranges|X-Content-Type-Options|Content-Type: application)/imu.test( + head, + ), + head, + ); + } finally { + connection.close(); + } + }); + } + + for (const index of [0, 1, 2]) { + test(`three sendFile() in progress, the ${["first", "second", "third"][index] ?? ""} settled once their cancellation is recorded, are restored as before the first`, async () => { + const connection = detached({ range: "bytes=0-1" }); + const { response } = connection; + try { + response.setHeader("X-Before", "kept"); + const { outcomes, held } = await overlapping(response, 3); + // The first file sets its type, which the next ones keep + deepStrictEqual(response.getHeader("Content-Type"), "application/pdf"); + const reason = new Error("Cancelled"); + response[kCancelling](reason); + held[index]?.destroy(new Error("A file has failed")); + deepStrictEqual(await outcomes[index], reason); + // Settled: not yet restored + deepStrictEqual(response.statusCode, 206); + response[kCancel](reason); + deepStrictEqual([response.statusCode, headersOf(response)], [200, []]); + deepStrictEqual(await Promise.all(outcomes), [reason, reason, reason]); + await within(response.send("FAILED"), deadline); + const head = connection.head(); + ok(head.startsWith("HTTP/1.1 200 OK\r\n"), head); + ok(/^Content-Type: text\/plain\r$/mu.test(head), head); + ok(!/^(?:ETag|Content-Range):/imu.test(head), head); + } finally { + connection.close(); + } + }); + } + + for (const recorded of [false, true]) { + test(`the framing set before the earliest sendFile(), whose send of a second file then fails${recorded ? " once their cancellation is recorded" : ""}, is not inherited by the reply that replaces them (R2-2)`, async () => { + const connection = detached({ range: "bytes=0-1" }); + const { response } = connection; + try { + response.setHeader("X-Before", "kept"); + // The framing of a body of the application, then replaced by files + response.set({ "Content-Length": "5", "Content-Range": "bytes */10" }); + const { outcomes, held } = await overlapping(response, 2); + const reason = new Error("Cancelled"); + if (recorded) { + response[kCancelling](reason); + } + const failure = new Error("The second file has failed"); + held[1]?.destroy(failure); + deepStrictEqual(await outcomes[1], recorded ? reason : failure); + response[kCancel](reason); + deepStrictEqual([response.statusCode, headersOf(response)], [200, []]); + deepStrictEqual(await outcomes[0], reason); + await within(response.send("FAILED"), deadline); + const head = connection.head(); + ok(head.startsWith("HTTP/1.1 200 OK\r\n"), head); + ok(/^Content-Length: 6\r$/mu.test(head), head); + ok(!/^(?:Content-Range|ETag):/imu.test(head), head); + ok(head.endsWith("\r\n\r\nFAILED"), head); + } finally { + connection.close(); + } + }); + } + + test("a send of a stream in progress is cancelled without its representation, forgotten once", async () => { + const connection = detached(); + const { response } = connection; + try { + const negotiator = new Negotiator(); + response[kNegotiator](negotiator); + response.setHeader("Content-Length", 8); + const sending = settled(response.send(new PassThrough())); + const reason = new Error("Cancelled"); + response[kCancel](reason); + deepStrictEqual( + [ + await sending, + negotiator.forgotten, + response.hasHeader("Content-Length"), + ], + [reason, 1, false], + ); + } finally { + connection.close(); + } + }); + + test("two sendFile() in progress are cancelled without the representation chosen for their files, forgotten once", async () => { + const connection = detached(); + const { response } = connection; + try { + const negotiator = new Negotiator(); + response[kNegotiator](negotiator); + const { outcomes } = await overlapping(response, 2); + const reason = new Error("Cancelled"); + response[kCancel](reason); + deepStrictEqual( + [ + await Promise.all(outcomes), + negotiator.forgotten, + headersOf(response), + ], + [[reason, reason], 1, []], + ); + } finally { + connection.close(); + } + }); + + test("a sendFile() cancelled before its file is opened is restored without the framing set before it, and its representation is forgotten", async () => { + const connection = detached(); + const { response } = connection; + try { + const negotiator = new Negotiator(); + response[kNegotiator](negotiator); + response.setHeader("Content-Length", 5); + const sending = settled(response.sendFile(files[0] ?? "")); + const reason = new Error("Cancelled"); + response[kCancel](reason); + deepStrictEqual( + [await sending, negotiator.forgotten, headersOf(response)], + [reason, 1, []], + ); + } finally { + connection.close(); + } + }); + + test("in an application, two sendFile() of the stack in progress once a pipe of the stack fails are replaced by the reply of the error handler without the headers of either file", async () => { + /** The streams of the files, whose first chunk never comes */ + const streams: PassThrough[] = []; + const { open } = fsp; + mock.method( + fsp, + "open", + async (...args: Parameters): Promise => { + const handle = await open(...args); + const create = handle.createReadStream.bind(handle); + handle.createReadStream = ( + ...options: Parameters + ): ReadStream => { + // Closes the file once it is destroyed (it reads nothing) + const stream = create(...options); + const proxy = new PassThrough(); + proxy.once("close", () => { + stream.destroy(); + }); + streams.push(proxy); + return proxy as unknown as ReadStream; + }; + return handle; + }, + ); + syncBuiltinESMExports(); + const failure = new Error("The pipe has failed"); + const outcomes: Promise[] = []; + const app = new Binden({ + logger: capture().logger, + error_handler: async (context: Context): Promise => { + await context.response.status(500).send("failed"); + }, + }); + app.use(async (context: Context) => { + for (const file of files.slice(0, 2)) { + outcomes.push(settled(context.response.sendFile(file))); + const sent = outcomes.length; + // Once its file is sent: its headers are set + await pollUntil(() => streams.length >= sent, deadline); + } + const source = new PassThrough(); + source.pipe(context.response); + source.prependListener("end", () => { + throw failure; + }); + source.end(); + await never(); + }); + const listening = await listen(app); + try { + const received = await send(listening.port, { ms: deadline }); + deepStrictEqual( + [ + received.status, + received.body, + fileHeaders.filter((name) => name in received.headers), + received.headers["content-type"], + ], + [500, "failed", ["content-length", "content-type"], "text/plain"], + ); + } finally { + await listening.close(); + } + deepStrictEqual(await Promise.all(outcomes), [failure, failure]); + }); +}); diff --git a/packages/binden/test/view-getters.test.ts b/packages/binden/test/view-getters.test.ts new file mode 100644 index 00000000..83754597 --- /dev/null +++ b/packages/binden/test/view-getters.test.ts @@ -0,0 +1,313 @@ +import { Binden, type Context } from "../index.js"; +import { listen, send, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** How a send has settled, and the reply that the client gets */ +interface ISent { + readonly status: number; + readonly length: unknown; + readonly body: string; + readonly outcome: string; + /** Whether the headers were sent once it has rejected (`null`: it has resolved) */ + readonly committed: boolean | null; +} + +/** + * Replies one request by `send()` of `data`, then (once it has rejected) by + * `500 replied`: the reply that the client gets, and how the send has + * settled (what it has rejected with is given to `failed`) + */ +async function sending( + data: unknown, + failed: (error: unknown) => void = (): void => { + // Only described + }, +): Promise { + const outcome = Promise.withResolvers<[string, boolean | null]>(); + const app = new Binden().use(async (context: Context) => { + try { + await context.send(data as Uint8Array); + outcome.resolve(["resolved", null]); + } catch (error: unknown) { + failed(error); + outcome.resolve([ + `${(error as Error).constructor.name}: ${(error as Error).message}`, + context.response.headersSent, + ]); + await context.response.status(500).send("replied"); + } + }); + const listening = await listen(app); + try { + const { status, headers, body } = await send(listening.port); + const [settled, committed] = await within(outcome.promise); + return { + status, + length: headers["content-length"], + body, + outcome: settled, + committed, + }; + } finally { + await listening.close(); + } +} + +/** + * Defines getters of the application on a view (or its prototype) that + * count their reads (`reads`) and return the values given + */ +function lying( + target: object, + values: Readonly>, + reads: { count: number }, +): void { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(target, key, { + configurable: true, + get(): unknown { + reads.count += 1; + return typeof value === "function" ? (value as () => unknown)() : value; + }, + }); + } +} + +/** An `ArrayBuffer` that has been detached (transferred) */ +function detachedBuffer(): ArrayBuffer { + const buffer = new ArrayBuffer(8); + buffer.transfer(); + return buffer; +} + +/** + * Runs `run` while `Error.prepareStackTrace` records each error whose stack + * V8 formats (once it is first read): what it resolves with, and the errors + * formatted meanwhile + */ +async function recording( + run: () => Promise, +): Promise<[T, WeakSet]> { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + const formatted = new WeakSet(); + Reflect.set( + Error, + "prepareStackTrace", + (error: Error, frames: readonly unknown[]): string => { + formatted.add(error); + return [String(error), ...frames.map(String)].join("\n at "); + }, + ); + try { + return [await run(), formatted]; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** + * Runs `fixtures/view-getters.ts` in a process of its own, without a handler + * of the process: a crash is its exit code and output + */ +function piped(mode: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--no-warnings", + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "view-getters.ts"), + mode, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite( + "A view is read by the getters of the engine only, and binden writes a Buffer of its own (R25-C1, R1 F1)", + () => { + test("given to send(): a subclass whose buffer, byteOffset and byteLength getters lie is sent as its bytes, none of its getters read", async () => { + class Lying extends Uint8Array {} + const reads = { count: 0 }; + lying( + Lying.prototype, + { buffer: detachedBuffer, byteOffset: 99, byteLength: 99 }, + reads, + ); + deepStrictEqual( + [await sending(new Lying([0x6f, 0x6b])), reads.count], + [ + { + status: 200, + length: "2", + body: "ok", + outcome: "resolved", + committed: null, + }, + 0, + ], + ); + }); + + test("given to send(): a view of a detached ArrayBuffer whose own getters tell another one is refused (a TypeError of binden), before anything is changed", async () => { + const bytes = new Uint8Array(2); + bytes.buffer.transfer(); + const reads = { count: 0 }; + lying( + bytes, + { + buffer: new Uint8Array([0x6f, 0x6b]).buffer, + byteOffset: 0, + byteLength: 2, + }, + reads, + ); + deepStrictEqual( + [await sending(bytes), reads.count], + [ + { + status: 500, + length: "7", + body: "replied", + outcome: "TypeError: The data is a view of a detached ArrayBuffer", + committed: false, + }, + 0, + ], + ); + }); + + test("given to send(): a view of a detached ArrayBuffer that is not a Uint8Array (a Float64Array, a DataView) is refused as detached, and a DataView out of the bounds of a resizable ArrayBuffer that has shrunk as out of bounds (TypeErrors of binden, formatted once created), before anything is changed; any other such view as any other object", async () => { + const floats = new Float64Array(1); + floats.buffer.transfer(); + const detached = new ArrayBuffer(2); + const view = new DataView(detached); + detached.transfer(); + const resizable = new ArrayBuffer(4, { maxByteLength: 4 }); + const outside = new DataView(resizable, 2); + resizable.resize(1); + const failures: unknown[] = []; + const [sent, formatted] = await recording(async () => { + const replies: string[] = []; + for (const data of [floats, view, outside, new Float64Array(1)]) { + const { status, body, outcome, committed } = await sending( + data, + (error) => failures.push(error), + ); + replies.push(`${status} ${body} ${String(committed)} ${outcome}`); + } + return replies; + }); + // Nothing else reads their stacks (nothing has been sent) + deepStrictEqual( + failures.map((error) => formatted.has(Object(error) as object)), + [true, true, true, true], + ); + deepStrictEqual(sent, [ + "500 replied false TypeError: The data is a view of a detached ArrayBuffer", + "500 replied false TypeError: The data is a view of a detached ArrayBuffer", + "500 replied false TypeError: The data is a view out of the bounds of its ArrayBuffer", + "500 replied false TypeError: The data is not a string, a number, a Buffer, a Uint8Array, a Readable nor a stream-like source (with on(), pause(), resume(), and removeListener() or off())", + ]); + }); + + test("given to send(): a view whose own byteLength getter would detach its buffer, or whose ArrayBuffer has a byteLength getter of its own that would detach it, is sent as its bytes, neither getter read", async () => { + const reads = { count: 0 }; + const own = new Uint8Array([0x6f, 0x6b]); + lying( + own, + { + byteLength: (): number => { + own.buffer.transfer(); + return 0; + }, + }, + reads, + ); + const { buffer } = new Uint8Array([0x6f, 0x6b]); + lying( + buffer, + { + byteLength: (): number => { + buffer.transfer(); + return 2; + }, + }, + reads, + ); + const sent = [await sending(own), await sending(new Uint8Array(buffer))]; + deepStrictEqual( + [sent.map(({ status, body }) => [status, body]), reads.count], + [ + [ + [200, "ok"], + [200, "ok"], + ], + 0, + ], + ); + }); + + test("given to send(): a typed array out of the bounds of a resizable ArrayBuffer that has shrunk has no bytes, as the engine tells", async () => { + const buffer = new ArrayBuffer(4, { maxByteLength: 4 }); + const bytes = new Uint8Array(buffer, 2, 2); + buffer.resize(1); + deepStrictEqual(await sending(bytes), { + status: 200, + length: "0", + body: "", + outcome: "resolved", + committed: null, + }); + }); + + test("a chunk of a Readable that is a view but not a Uint8Array (a DataView, a Uint16Array) is written as its bytes", async () => { + const bytes = new Uint8Array([0x6f, 0x6b, 0x21, 0x21]).buffer; + const { status, body, outcome } = await sending( + Readable.from([new DataView(bytes, 0, 2), new Uint16Array(bytes, 2)], { + objectMode: true, + }), + ); + deepStrictEqual([status, body, outcome], [200, "ok!!", "resolved"]); + }); + + test("the first chunk of a Readable that is a DataView out of the bounds of a resizable ArrayBuffer that has shrunk: the send rejects with a TypeError of binden before the headers are committed, and the work replies", async () => { + const buffer = new ArrayBuffer(4, { maxByteLength: 4 }); + const view = new DataView(buffer, 2); + buffer.resize(1); + deepStrictEqual( + await sending(Readable.from([view], { objectMode: true })), + { + status: 500, + length: "7", + body: "replied", + outcome: + "TypeError: The data is a view out of the bounds of its ArrayBuffer", + committed: false, + }, + ); + }); + + test("a chunk of a Readable whose own byteLength getter would detach its buffer once a chunk is written, or a first chunk of a subclass whose getters lie, is written as its bytes, none of its getters read, without a crash", () => { + for (const [mode, body] of [ + ["detaching", "starta"], + ["lying", "ok"], + ]) { + deepStrictEqual( + piped(String(mode)), + { + status: 0, + stdout: `${JSON.stringify({ send: "resolved", reads: 0, body, second: "second" })}\n`, + stderr: "", + }, + mode, + ); + } + }); + }, +); diff --git a/packages/binden/tsconfig.json b/packages/binden/tsconfig.json new file mode 100644 index 00000000..9536a0f4 --- /dev/null +++ b/packages/binden/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json" +} 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/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..d542516c --- /dev/null +++ b/packages/body/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/packages/body/README.md b/packages/body/README.md new file mode 100644 index 00000000..28ed1e0f --- /dev/null +++ b/packages/body/README.md @@ -0,0 +1,309 @@ +# @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: 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 + +| 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` | + +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 + +```bash +npm install @binden/body +``` + +## Usage + +```typescript +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 | +| `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 | + +An invalid option throws a `TypeError` (or a `RangeError`) in the constructor, and so do options +that are not an object (or `undefined`: "`options` is not an object: …"); what reading them throws +(e.g. a revoked proxy) is formatted, as by every entry point of binden. 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. A coded body may be + much larger than what it decodes to (e.g. a `deflate` stream of empty blocks): only + `encoded_limit` bounds the bytes that are read, and kept for `verify_encoded`. +- `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 + +`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. + +```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, +}); +``` + +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, +}); +``` + +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"; + +// JSON, text and forms by the first, `multipart/form-data` by the second +app.use(new BodyParser({ limit: 1024 * 1024 }), new Multipart()); +``` + +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`). `identity` is no coding (RFC 9110): it is ignored, as in +`Content-Encoding: identity`. 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. + +Data after the end of a coding (e.g. after the end of a `deflate` or `br` stream, or zero padding +after a `gzip` member or a `zstd` frame), which the decoders of Node.js would ignore while they go +on reading it, is corrupt data too: the reading stops there. So is a body that has a coding but not +a single byte, whatever the coding; a coding of an empty content (e.g. an empty `gzip` member) is +an empty body. + +`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 `zstd` coding (its frames are decoded one at a time). + +Concatenated `gzip` members are one body, and so are concatenated `zstd` frames (RFC 8878), on +every supported version of Node.js: the parser finds the frames itself, and decodes them one after +the other, each data frame by a decoder of its own (but an empty one), which gets the bytes of that +frame only once they have been read. Once the decoder of a frame has ended, before the next frame is decoded, the +size of its content is checked against the `Frame_Content_Size` that the frame declares, if it +declares one: a different size is a `400`, however the body is split (and a frame that produces +more than it declares fails at once). Skippable frames are ignored, and a truncated frame is a +`400`. `limit` counts the content of all the frames, `encoded_limit` and `verify_encoded` the body +as received, skippable frames included. + +A request has at most **1024 `zstd` frames**, on every supported version of Node.js: its data, +empty and skippable frames, over all its `zstd` codings (e.g. both codings of +`Content-Encoding: zstd, zstd`). Each frame is a step of the decoding, so that a body of many tiny +frames would cost much more than its size: the next frame gets `413 Content Too Large` (a +`BindenError` of code `zstd_frames`), before the decoder gets any byte of the chunk where it +starts. A compressed body is usually one frame; a client that compresses a stream may write a +frame per flush, which must stay within the limit. + +Its **decoders are limited to 64 MiB** too, over all its `zstd` codings: a decoder allocates the +window of its frame (or its declared content size when smaller) and its buffers, so that a small +body of frames that each declare a large window would cost much more memory and garbage collection +than its size. Each frame that needs a decoder is charged its window, or its `Frame_Content_Size` +when smaller, and at least 256 KiB: a request has at most 256 decoders (e.g. of frames of a few +bytes each), and 8 of frames that declare a window of 8 MiB and no size. The next one gets the same +`413` (`zstd_frames`), before its decoder gets any byte. An empty frame (whose blocks are all empty, +of a declared size of 0 if any) needs no decoder and is not charged: the parser checks it as a +decoder would (its reserved bit, no dictionary, a window within 8 MiB, its blocks, and a checksum +that is that of no content). + +## 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"; +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; +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, login)) + .createServer() + .listen(port, () => { + console.log(`Server is listening on the port ${port}`); + }); +``` + +## 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, `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`), also + when the parser is still reading once the response has finished (e.g. it was not awaited): then + `context.signal` is no longer aborted, but the connection closes before the body has been + received whole. +- After the deadline of the request, the parser rejects with the reason of the signal (a + `BindenAbortError` of reason `deadline`): the framework replies `503 Service Unavailable`, and + closes the connection. When the timeout of a shutdown passes, the parser rejects with the + reason `shutdown`, and the connection is closed without a reply. + +A parser must start before the response has finished: Node.js then reads and discards what has +not been read of the body. A parser that starts later (e.g. after a middleware that has replied +without `done`) refuses the body with a `500 Internal Server Error` (logged at `error`), never +with an empty body. + +`run()` reads each property of its context once, before anything is awaited (the request, the +response, the signal and the logger). What getting the logger throws (e.g. a `log` getter of a +subclass of `Context`) drops the records of the parse only, and the parse goes on. What a later use of them throws (but a call of the logger, +which never fails, see the Logging of binden) stops the parse as any other failure does: the +request is unpiped, the decoders destroyed, the listeners removed, and the connection closed after +the reply. Examples are a context that only looks like one, such as a proxy whose response's +`setHeader()` throws as a chunked body goes over the limit, or whose signal's +`removeEventListener()` throws once the body is read. That error is the rejection of `run()`: the +first such error, as it is, formatted. It never crashes the process nor leaves `run()` pending, and +what the parse throws once something has been awaited (e.g. the setter of `request.body`) is +formatted too. + +## 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 (also data after the end of a coding, no byte at all, or a `zstd` frame not of its declared size), 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` | +| More than 1024 `zstd` frames in a request, or more than 64 MiB of their decoders | `413` (`zstd_frames`) | +| 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` 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` | +| A parser that starts once the response has finished | `500 Internal Server Error` | +| A use of the context (but its logger) that throws once the parse has started | that error, formatted (`500`) | + +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 new file mode 100644 index 00000000..0e8876d9 --- /dev/null +++ b/packages/body/index.ts @@ -0,0 +1,1047 @@ +import { + BindenAbortError, + BindenError, + ContentEncoding, + type ContentType, + type Context, + type IMiddlewareParams, + Middleware, + childLogger, + described, + formatStack, + formatting, + record, + serializers, +} from "binden"; +import { Duplex, Transform, Writable, pipeline } from "node:stream"; +import { type IZstdBudget, ZstdDecoding } from "./src/zstd.js"; +import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; +import { addAbortListener } from "node:events"; + +/** How a selected body is parsed: JSON (with `parse`), text, a form or raw bytes */ +export type IBodyKind = "form" | "json" | "raw" | "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); + +/** + * 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; + +/** 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 { + /** + * 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 + */ + 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; + /** 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; + /** + * Whether the `zstd` content coding is decoded (`false`): the API of + * Node.js is experimental. A request has at most 1024 `zstd` frames and + * 64 MiB of their decoders (more get a `413 Content Too Large` of code + * `zstd_frames`). + */ + zstd?: boolean | undefined; +} + +/** A decoding stream (of Node.js), which counts the bytes that it has consumed */ +type IDecoding = Duplex & { readonly bytesWritten: number }; + +/** + * A decoder of a content coding, created once every coding is known to be + * supported: the `zstd` codings of a request share one `budget` + */ +type IDecoder = (budget: IZstdBudget) => IDecoding; + +/** 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 (a child of `context.log`) */ +type ILog = Context["log"]; + +/** What the parse of a body uses of its context, each read once (see `#start()`) */ +interface IExchange { + readonly request: Context["request"]; + readonly response: Context["response"]; + readonly signal: AbortSignal; +} + +/** The body as read (see `#read()`): decoded, and as received with `verify_encoded` */ +interface IRead { + readonly decoded: Buffer; + readonly encoded: Buffer | null; +} + +/** A parse that has started (see `#start()`), with what the rest of it needs */ +interface IStarted { + readonly request: Context["request"]; + readonly kind: IBodyKind; + readonly decoder: TextDecoder | null; + readonly log: ILog; + readonly signal: AbortSignal; + readonly read: Promise; +} + +/** + * The first failure of a late call of a context, formatted: the rejection of + * its parse (what is thrown may be anything, an error or not) + */ +interface IFailure { + readonly error: Error; +} + +/** The most content codings that a body may have */ +const MAX_CODINGS = 2; + +/** The error of the data after the end of a content coding */ +const TRAILING = "Data after the end of a content coding"; + +/** The error of a parser that starts once the response has finished */ +const DISCARDED = "The body has been discarded once the response finished"; + +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; +} + +/** + * The content codings of a body, in the order to remove them, or `null` when + * one is unknown: `identity` is no coding (RFC 9110, section 8.4.1) + */ +function codingsOf(header: string | undefined): ContentEncoding[] | null { + const codings = (header ?? "") + .split(",") + .filter((element) => element.trim().toLowerCase() !== "identity"); + return ContentEncoding.fromString(codings.join(",")); +} + +/** A limit: a non-negative integer, or `Infinity` */ +function validateLimit(name: string, value: unknown): number { + if (typeof value !== "number" || Number.isNaN(value)) { + throw formatStack(new TypeError(`\`${name}\` is not a number`)); + } else if ( + value !== Infinity && + !(Number.isSafeInteger(value) && value >= 0) + ) { + throw formatStack( + 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 formatStack(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 formatStack(new TypeError("`type` is not an array or a function")); + } + return type.map((entry: unknown) => { + if (typeof entry !== "string" || !mediaRange.test(entry)) { + throw formatStack( + new TypeError(`Invalid media type in \`type\`: ${described(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 (and keeps them in `kept`, if any): + * more than `limit` fails it with a `413 Content Too Large` + */ +function counter(limit: number, kept: Buffer[] | null): Transform { + let size = 0; + return new Transform({ + transform(chunk: Buffer, _encoding, callback): void { + size += chunk.byteLength; + if (size > limit) { + callback(new BindenError(413)); + return; + } + kept?.push(chunk); + callback(null, chunk); + }, + }); +} + +/** + * Passes the bytes of a body to a decoder, and fails once the decoder has + * ended: the decoders of Node.js ignore (and keep reading) what follows the + * end of what they decode, which is an invalid body. Adds to `trailing` the + * check of whether the decoder has left some of the bytes that it has been + * given (e.g. in the chunk where its stream ends), once the body is read. + */ +function guard(decoder: IDecoding, trailing: (() => boolean)[]): Transform { + let given = 0; + trailing.push(() => decoder.bytesWritten < given); + return new Transform({ + transform(chunk: Buffer, _encoding, callback): void { + if (decoder.readableEnded) { + callback(formatStack(new Error(TRAILING))); + return; + } + given += chunk.byteLength; + callback(null, chunk); + }, + }); +} + +/** The options of a `BodyParser`, each read once (see `readOptions()`) */ +interface IReadOptions { + readonly parse: unknown; + readonly limit: unknown; + readonly encoded_limit: unknown; + readonly type: unknown; + readonly raw: unknown; + readonly strict: unknown; + readonly parameter_limit: unknown; + readonly verify: unknown; + readonly verify_encoded: unknown; + readonly zstd: unknown; + readonly rest: IMiddlewareParams; +} + +/** The settings of a `BodyParser`, validated */ +interface ISettings { + 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; + readonly verify: IVerify | null; + readonly verify_encoded: boolean; + readonly zstd: boolean; +} + +/** The media types that a `BodyParser` parses by default */ +const defaultTypes: readonly string[] = [ + "application/json", + "+json", + "text/plain", + "application/x-www-form-urlencoded", +]; + +/** + * Reads the options of a `BodyParser` once, with their defaults: anything but + * an object is refused with a `TypeError` before anything is read, which + * describes it (see `described()`) + */ +function readOptions(options: unknown): IReadOptions { + if (typeof options !== "object" || options === null) { + throw formatStack( + new TypeError(`\`options\` is not an object: ${described(options)}`), + ); + } + const { + parse, + limit = Infinity, + encoded_limit = Infinity, + type, + raw = false, + strict = true, + parameter_limit = 1000, + verify, + verify_encoded = false, + zstd = false, + ...rest + } = options as IBodyParserOptions; + return { + parse, + limit, + encoded_limit, + type, + raw, + strict, + parameter_limit, + verify, + verify_encoded, + zstd, + rest, + }; +} + +/** Validates the options of a `BodyParser` (see `readOptions()`) */ +function settingsOf({ + parse, + limit, + encoded_limit, + type, + raw, + strict, + parameter_limit, + verify, + verify_encoded, + zstd, +}: IReadOptions): ISettings { + if (typeof parse !== "undefined" && typeof parse !== "function") { + throw formatStack(new TypeError("`parse` is not a function")); + } else if (typeof verify !== "undefined" && typeof verify !== "function") { + throw formatStack(new TypeError("`verify` is not a function")); + } + const limits = { + limit: validateLimit("limit", limit), + encoded_limit: validateLimit("encoded_limit", encoded_limit), + parameter_limit: validateLimit("parameter_limit", parameter_limit), + }; + const raws = validateBoolean("raw", raw); + const flags = { + strict: validateBoolean("strict", strict), + verify_encoded: validateBoolean("verify_encoded", verify_encoded), + zstd: validateBoolean("zstd", zstd), + }; + + let types: readonly string[] | null = null; + let select: ((content_type: ContentType) => unknown) | null = null; + if (typeof type === "function") { + if (raws) { + throw formatStack( + new TypeError("`raw` cannot be used with a `type` function"), + ); + } + select = type as (content_type: ContentType) => unknown; + } else { + const listed = + typeof type === "undefined" ? defaultTypes : validateTypes(type); + types = raws ? [...listed, "application/octet-stream"] : listed; + } + + return { + ...limits, + ...flags, + types, + type: select, + // `JSON.parse` itself is the default parse + parse: + typeof parse === "undefined" || parse === JSON.parse + ? null + : (parse as IParse), + verify: (verify as IVerify | undefined) ?? null, + }; +} + +/** + * 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 | 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; + readonly #verify: IVerify | null; + 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`), and so does anything but an object + * (or `undefined`, the defaults). What reading them throws (e.g. a revoked + * proxy, or a getter that throws) is formatted (see `formatting()`). + */ + public constructor(options: IBodyParserOptions = {}) { + const read = formatting(() => readOptions(options)); + super(read.rest); + const settings = formatting(() => settingsOf(read)); + this.#parse = settings.parse; + this.#limit = settings.limit; + this.#encoded_limit = settings.encoded_limit; + this.#types = settings.types; + this.#type = settings.type; + this.#strict = settings.strict; + this.#parameter_limit = settings.parameter_limit; + this.#verify = settings.verify; + this.#verify_encoded = settings.verify_encoded; + this.#zstd = settings.zstd; + } + + /** + * 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 { + // What reading the context throws (e.g. of a value that is not a + // context) is formatted (see `formatting()`): it is read before anything + // is awaited, each of its properties once, and what it gives is kept + const started = formatting(() => this.#start(context)); + if (started === null) { + return; + } + try { + await this.#complete(context, started); + } catch (error: unknown) { + // Also what the context gives throws once something has been awaited + // (e.g. its signal or the setter of `request.body`) + throw formatStack(error as Error); + } + } + + /** Verifies and parses the body once it is read (see `run()`) */ + async #complete( + context: Context, + { request, kind, decoder, log, signal, read }: IStarted, + ): Promise { + const { decoded, encoded } = await read; + + 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), + signal, + log, + ); + if (!outcome.ok) { + record(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) { + try { + text = decoder.decode(decoded); + } catch (error: unknown) { + record(log, "debug", "The body is not valid in its charset", { + charset: decoder.encoding, + }); + throw new BindenError(400, { cause: error }); + } + } + + if (kind === "json" && text === "") { + // An empty JSON body has no value (`undefined`) + return; + } + request.body = + kind === "json" + ? await this.#json(text, signal, log) + : kind === "form" + ? this.#form(text, log) + : kind === "text" + ? text + : decoded; + } + + /** + * Starts the parse of the body of the request of a context (see `run()`): + * `null` when it is not parsed, otherwise the read of the body (see + * `#read()`), and what the rest of the parse needs of the context + */ + #start(context: Context): IStarted | null { + const { request, signal } = context; + // Read once (see `run()`): what getting it throws (e.g. a `log` getter of + // a subclass of `Context`) drops the records of the parse, never the + // parse, as binden drops its own (a logger of binden over nothing drops + // them) + let parent: unknown = null; + try { + parent = context.log; + } catch { + // Dropped + } + // Made by the private path of binden: never a `child()` (nor a method) + // that the application has replaced + const log = childLogger(parent as Context["log"], { + middleware: BodyParser.name, + }); + const { method = "GET", content_type } = request; + + if (BodyParser.#methods.has(method)) { + record(log, "debug", "A method without a body", { method }); + return null; + } + + const kind = content_type === null ? null : this.#kind(content_type, log); + if (kind === null) { + record(log, "debug", "The `Content-Type` is not selected", { + content_type: content_type?.type ?? null, + }); + return null; + } else if (claimed.has(request)) { + record(log, "debug", "The body has already been parsed"); + return null; + } + claimed.add(request); + + const exchange: IExchange = { request, response: context.response, signal }; + const { decoders, decoder } = this.#prepare( + exchange, + content_type as ContentType, + kind, + log, + ); + const read = this.#read(exchange, decoders, log); + return { request, kind, decoder, log, signal, read }; + } + + /** 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; + } + + const select = this.#type as (content_type: ContentType) => unknown; + const outcome = attempt(() => select(content_type)); + if (!outcome.ok) { + record(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); + record(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( + { request, response }: IExchange, + content_type: ContentType, + kind: IBodyKind, + log: ILog, + ): { decoders: IDecoder[]; decoder: TextDecoder | null } { + const refuse = (error: Error, message: string, extra?: object): Error => { + record(log, "debug", message, { ...extra }); + if (!response.headersSent) { + response.setHeader("Connection", "close"); + } + return error; + }; + + // An aborted signal stops the reading (see `#read()`) + if (response.writableFinished) { + // Node.js reads and discards what has not been read once the response + // has finished: what is left of the body would be missing + record(log, "error", DISCARDED); + throw new BindenError(500, { message: DISCARDED }); + } else if (request.readableDidRead || request.readableEnded) { + // E.g. by a middleware that has read the stream itself + record(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 codings = codingsOf(request.headers["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, + }); + } + + const decoders: IDecoder[] = []; + for (const { encoding } of codings) { + const decoder = this.#decoder(encoding); + if (decoder === null) { + throw refuse(new BindenError(415), "Unsupported content coding", { + encoding, + }); + } + decoders.push(decoder); + } + + let decoder: TextDecoder | null = null; + if (kind !== "raw") { + const charset = 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 }); + } + } + + 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 }; + } + + /** The decoder of a content coding, `null` when it is not supported */ + #decoder(encoding: string): IDecoder | null { + if (encoding === "gzip" || encoding === "x-gzip") { + return (): IDecoding => createGunzip(); + } else if (encoding === "deflate") { + return (): IDecoding => createInflate(); + } else if (encoding === "br") { + return (): IDecoding => createBrotliDecompress(); + } else if (encoding === "zstd" && this.#zstd) { + return (budget): IDecoding => new ZstdDecoding(budget); + } + return null; + } + + /** + * 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. A client + * that goes away is an abort, also once the response has finished. What a + * later call of the request, the response, the signal or the logger + * throws (e.g. of a context that only looks like one) stops it the same + * way, and is the rejection, formatted (the first one, see `IFailure`). + */ + #read( + { request, response, signal }: IExchange, + decoders: readonly IDecoder[], + log: ILog, + ): Promise { + const kept: Buffer[] | null = + this.#verify_encoded && decoders.length > 0 ? [] : null; + const wire = counter(this.#encoded_limit, kept); + const stages: Duplex[] = [wire]; + const trailing: (() => boolean)[] = []; + // Over every `zstd` coding of the body + const budget: IZstdBudget = { frames: 0, memory: 0 }; + for (const create of decoders) { + const decoder = create(budget); + stages.push( + guard(decoder, trailing), + decoder, + counter(this.#limit, null), + ); + } + if (decoders.length === 0) { + stages.push(counter(this.#limit, null)); + } + 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; + let failure: IFailure | null = null; + const cleanups: (() => void)[] = []; + const keep = (error: unknown): IFailure => { + failure ??= { error: formatStack(error as Error) }; + return failure; + }; + // A call of the context: what it throws is recorded, and the parse + // goes on stopping (or ending) + const guarded = (call: () => void): void => { + try { + call(); + } catch (error: unknown) { + keep(error); + } + }; + const settle = (): boolean => { + if (settled) { + return false; + } + settled = true; + for (const cleanup of cleanups) { + guarded(cleanup); + } + return true; + }; + const fail = (error: Error, message: string): void => { + if (!settle()) { + return; + } + guarded(() => { + record(log, "debug", message, { failure: serializers.error(error) }); + }); + // Paused (once nothing else reads it), not destroyed: the reply is sent + guarded(() => { + request.unpipe(wire); + }); + for (const stream of [...stages, sink]) { + stream.destroy(); + } + guarded(() => { + if (!response.headersSent) { + response.setHeader("Connection", "close"); + } + }); + reject(failure === null ? error : failure.error); + }; + // A callback of the signal or of the streams of the context: what it + // throws stops the parse + const late = (call: () => void): void => { + try { + call(); + } catch (error: unknown) { + fail(keep(error).error, "A call of the context has failed"); + } + }; + + // What Node.js throws for what the context has given (e.g. a signal + // that is not an `AbortSignal`) stops it too, formatted + try { + const listener = addAbortListener(signal, () => { + late(() => { + fail(abortError(signal.reason), "The request has been aborted"); + }); + }); + cleanups.push(() => { + listener[Symbol.dispose](); + }); + // Not `finished()`, whose listeners read the request again, outside + // of any guard: an error of the request, or a close before its end, + // is an abort + let ended = false; + const onEnd = (): void => { + ended = true; + }; + const onError = (error: unknown): void => { + late(() => { + fail(abortError(error), "The request has been aborted"); + }); + }; + const onClose = (): void => { + if (!ended) { + late(() => { + fail(abortError(null), "The request has been aborted"); + }); + } + }; + request.once("end", onEnd).on("error", onError).once("close", onClose); + cleanups.push(() => { + request.off("end", onEnd).off("error", onError).off("close", onClose); + }); + 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 (trailing.some((left) => left())) { + const cause = formatStack(new Error(TRAILING)); + const invalid = new BindenError(400, { cause }); + fail(invalid, "The body cannot be decoded"); + } else if (settle()) { + if (failure === null) { + resolve({ + decoded: Buffer.concat(chunks), + encoded: kept === null ? null : Buffer.concat(kept), + }); + } else { + // A cleanup of the context has failed + reject(failure.error); + } + } + }); + request.pipe(wire); + + // Once the response has finished, Node.js no longer tells the request + // that the client has gone away, but its connection closes. A body that + // has been received whole is still read. (A parser does not start once + // the response has finished, and before, a closed connection has + // destroyed the request.) + const { socket } = request; + const closed = (): void => { + late(() => { + if (!request.complete) { + fail(abortError(null), "The request has been aborted"); + } + }); + }; + socket.once("close", closed); + cleanups.push(() => { + socket.off("close", closed); + }); + } catch (error: unknown) { + fail(keep(error).error, "The body cannot be read"); + } + }); + } + + /** + * 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, signal: AbortSignal, log: ILog): Promise { + if (text.trim() === "") { + record(log, "debug", "The JSON body has only whitespace"); + throw new BindenError(400); + } + + if (this.#parse === null) { + if (this.#strict && !strictJSON.test(text)) { + record(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) { + record(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), signal, log); + if (!outcome.ok) { + record(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 }; + record(log, "debug", "`parse` has not parsed the body"); + throw new BindenError(400, { + ...(typeof message === "string" ? { message } : {}), + }); + } + return outcome.value; + } + + /** The parameters of a form, at most `parameter_limit` of them */ + #form(text: string, log: ILog): URLSearchParams { + if (tooManyParameters(text, this.#parameter_limit)) { + record(log, "debug", "The form has too many parameters", { + parameter_limit: this.#parameter_limit, + }); + throw new BindenError(413); + } + return new URLSearchParams(text); + } + + /** + * 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 + */ + static async #call( + call: () => R | PromiseLike, + signal: AbortSignal, + log: ILog, + ): Promise> { + const outcome = await new Promise | null>((resolve, reject) => { + // Called (in a microtask) when the signal has already been aborted + const listener = addAbortListener(signal, () => { + resolve(null); + }); + // What removing the listener throws (e.g. of a signal that only looks + // like one) is the rejection: the outcome is observed either way + const done = (settled: IOutcome): void => { + try { + listener[Symbol.dispose](); + resolve(settled); + } catch (error: unknown) { + reject(formatStack(error as Error)); + } + }; + Promise.resolve() + .then(call) + .then( + (value) => { + done({ ok: true, value }); + }, + (error: unknown) => { + done({ ok: false, error }); + }, + ); + }); + if (outcome === null) { + record(log, "debug", "The request has been aborted"); + throw abortError(signal.reason); + } + return outcome; + } + + /** 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", + ]); +} + +/** + * A `BodyParser` with the default options: without limits (set `limit` and + * `encoded_limit` in production) + */ +export default new BodyParser(); diff --git a/packages/body/package.json b/packages/body/package.json new file mode 100644 index 00000000..88bb9fa1 --- /dev/null +++ b/packages/body/package.json @@ -0,0 +1,70 @@ +{ + "name": "@binden/body", + "version": "0.0.0-development", + "description": "Binden body parser middleware", + "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" + }, + "sideEffects": false, + "files": [ + "dist", + "!dist/test", + "index.ts", + "src" + ], + "engines": { + "node": ">=24.14.1" + }, + "scripts": { + "build": "tsc", + "prebuild": "node ../../scripts/clean.mjs", + "prepack": "npm run build", + "test": "node ../../scripts/test.mjs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/body" + }, + "keywords": [ + "HTTP", + "binden", + "body", + "body-parser", + "middleware", + "json", + "urlencoded", + "form", + "text", + "raw", + "decompression" + ], + "author": "Sergey Bakulin ", + "license": "MIT", + "bugs": { + "url": "https://github.com/binden-js/binden/issues" + }, + "homepage": "https://github.com/binden-js/binden/tree/main/packages/body#readme", + "peerDependencies": { + "binden": "^0.0.0-development" + }, + "devDependencies": { + "@binden/logger": "^0.0.0-development", + "ajv": "^8.20.0", + "undici": "^8.11.2" + }, + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } +} diff --git a/packages/body/src/zstd.ts b/packages/body/src/zstd.ts new file mode 100644 index 00000000..0d6fd0dd --- /dev/null +++ b/packages/body/src/zstd.ts @@ -0,0 +1,572 @@ +// The decoding of the `zstd` content coding (RFC 8878) for `BodyParser`: an +// internal module, which the package does not export +import { BindenError, formatStack } from "binden"; +import { + type ZstdDecompress, + constants, + createZstdDecompress, +} from "node:zlib"; +import { Duplex } from "node:stream"; + +/** + * A part of `zstd` data (RFC 8878, section 3.1): the magic number of a frame, + * the descriptor, the `Window_Descriptor` and `Dictionary_ID` (`header`) and + * the `Frame_Content_Size` (`fcs`) of a frame, the header (`block`) and the + * `content` of a block, the `checksum` of a frame, or the `size` and the data + * (`skip`) of a skippable frame + */ +type IZstdPart = + | "block" + | "checksum" + | "content" + | "descriptor" + | "fcs" + | "header" + | "magic" + | "size" + | "skip"; + +/** + * What the `zstd` codings of a request have cost so far, shared by their + * decodings: their frames, and the memory charged for their decoders + */ +export interface IZstdBudget { + frames: number; + memory: number; +} + +/** + * Bytes of a data frame within a chunk, for the decoder of that frame: the + * last piece of a frame (`end`) ends it + */ +export interface IZstdPiece { + readonly bytes: Buffer; + /** + * For the first piece of a frame, which starts its decoder, the bytes of + * the frame before it: its magic number and its header, and the header of + * its first non-empty block when the decoder starts there (the empty blocks + * before it, which produce nothing, are left out); `null` for the others + */ + readonly head: Buffer | null; + readonly end: boolean; + /** + * The `Frame_Content_Size` of the frame: `null` when the frame does not + * declare it + */ + readonly declared: bigint | null; +} + +/** + * The most `zstd` frames of a request (data, empty and skippable ones), over + * every `zstd` coding of its body: each frame is a step of the decoding, so + * that a body of tiny frames would cost much more than its size + */ +export const ZSTD_MAX_FRAMES = 1024; + +/** + * The most memory charged for the decoders of a request (64 MiB), over every + * `zstd` coding of its body: each data frame that needs a decoder (every one + * but an empty frame) is charged `ZSTD_DECODER_CHARGE` or the memory of its + * window, which a decoder allocates (and the garbage collector reclaims) + */ +export const ZSTD_MAX_DECODER_MEMORY = 64 * 1024 * 1024; + +/** + * The least charge of a decoder (256 KiB), for its context and its buffers: + * a frame is charged its window, or its declared content size when smaller, + * and at least this (at most 256 decoders per request) + */ +export const ZSTD_DECODER_CHARGE = 256 * 1024; + +/** The largest window of a `zstd` frame (8 MiB, RFC 9659) */ +const ZSTD_WINDOW_LOG_MAX = 23; + +/** The largest window of a `zstd` frame, in bytes */ +const ZSTD_WINDOW_MAX = 2 ** ZSTD_WINDOW_LOG_MAX; + +/** The most bytes of a block (128 KiB, RFC 8878, section 3.1.1.2.4), or of its window if smaller */ +const ZSTD_BLOCK_MAX = 128 * 1024; + +/** The magic number of a `zstd` frame (RFC 8878, section 3.1.1) */ +const ZSTD_MAGIC = 0xfd2fb528; + +/** The magic numbers of the skippable frames, without their last 4 bits (RFC 8878, section 3.1.2) */ +const ZSTD_SKIPPABLE = 0x184d2a5; + +/** + * The `Content_Checksum` of an empty content: the low 4 bytes of the XXH64 + * of no data (RFC 8878, section 3.1.1) + */ +const ZSTD_EMPTY_CHECKSUM = 0x51d8e999; + +/** The error of `zstd` data that is not a frame (e.g. after the end of the last one) */ +const ZSTD_INVALID = "Invalid zstd frame"; + +/** The error of `zstd` data that has no frame, or ends within one */ +const ZSTD_TRUNCATED = "Unexpected end of zstd data"; + +/** The error of a frame whose content is not of the size that it declares */ +const ZSTD_SIZE = "The content of a zstd frame is not of its declared size"; + +/** The error of a frame whose window is larger than 8 MiB */ +const ZSTD_WINDOW = "The window of a zstd frame is larger than 8 MiB"; + +/** The error of a frame that needs a dictionary */ +const ZSTD_DICTIONARY = "A zstd frame needs a dictionary"; + +/** The error of an empty frame whose checksum is not that of an empty content */ +const ZSTD_CHECKSUM = "The checksum of a zstd frame does not match its content"; + +/** The parts of `zstd` data that are read (the others are only counted) */ +const zstdFields: ReadonlySet = new Set([ + "block", + "checksum", + "descriptor", + "fcs", + "header", + "magic", + "size", +]); + +/** The sizes of a `Dictionary_ID`, by its flag */ +const dictionarySizes = [0, 1, 2, 4] as const; + +/** + * Reads `zstd` data, and finds the ends of its frames (RFC 8878, section 3.1) + * by their headers and the headers of their blocks, and the content size + * that each frame declares. What is not a frame throws, and so does a frame + * of the request beyond `ZSTD_MAX_FRAMES`, or a decoder beyond + * `ZSTD_MAX_DECODER_MEMORY` (a `413 Content Too Large`, of code + * `zstd_frames`). An empty frame, whose blocks are all empty, gets no decoder: + * it is checked here as a decoder would check it (its header, its declared + * size of 0 if any, its blocks and its checksum). + */ +export class ZstdFrames { + readonly #budget: IZstdBudget; + /** The bytes of the part being read, when it is a field (8 bytes at most) */ + readonly #field = Buffer.alloc(8); + /** + * The bytes of the frame before its decoder starts: its magic number and + * its header (18 bytes at most), and the header of a block + */ + readonly #head = Buffer.alloc(21); + #kept = 0; + #part: IZstdPart = "magic"; + /** The size of the part, and how much of it has been read */ + #need = 4; + #have = 0; + /** Whether the frame is a single segment and has a checksum, and the block is its last one */ + #single = false; + #checksum = false; + #last = false; + /** The size of the `Dictionary_ID` of the frame, and of its `Frame_Content_Size` */ + #dictionary = 0; + #fcs = 0; + /** The window of the frame, and its declared content size */ + #window = 0; + #declared: bigint | null = null; + /** Whether the frame has a decoder, and its head once it has just got it */ + #decoding = false; + #started: Buffer | null = null; + + /** Frames whose cost counts in `budget` (shared by the codings of a request) */ + public constructor(budget: IZstdBudget) { + this.#budget = budget; + } + + /** Whether the data ends between two frames (or before the first one) */ + public get complete(): boolean { + return this.#part === "magic" && this.#have === 0; + } + + /** + * Reads a chunk whole: the pieces of the data frames in it that have a + * decoder, in order (the skippable and empty frames are not in them, and + * the bytes of two frames are never in one piece) + */ + public scan(chunk: Buffer): IZstdPiece[] { + const pieces: IZstdPiece[] = []; + // Where the bytes of a decoded frame start in the chunk (`-1` outside of + // one), and the head of the frame when its decoder starts in the chunk + let from = this.#decoding ? 0 : -1; + let head: Buffer | null = null; + let offset = 0; + while (offset < chunk.byteLength) { + const take = Math.min(this.#need - this.#have, chunk.byteLength - offset); + if (zstdFields.has(this.#part)) { + chunk.copy(this.#field, this.#have, offset, offset + take); + } + this.#have += take; + offset += take; + if (this.#have === this.#need) { + do { + this.#next(); + } while (this.#need === 0); + if (this.#started !== null) { + // The decoder of the frame starts here (before its end: what has + // started it, a declared size or a block, is followed by more) + head = this.#started; + this.#started = null; + from = offset; + } else if (this.#part === "magic" && from !== -1) { + // The end of a decoded frame + const bytes = chunk.subarray(from, offset); + pieces.push({ bytes, head, end: true, declared: this.#declared }); + from = -1; + head = null; + } + } + } + if (from !== -1) { + const bytes = chunk.subarray(from); + pieces.push({ bytes, head, end: false, declared: this.#declared }); + } + return pieces; + } + + /** Moves to the part after the one that has been read */ + #next(): void { + const part = this.#part; + if (part === "magic") { + this.#magic(); + } else if (part === "descriptor") { + this.#descriptor(); + } else if (part === "header") { + this.#header(); + } else if (part === "fcs") { + this.#size(); + } else if (part === "content" && !this.#last) { + this.#to("block", 3); + } else if (part === "block") { + this.#block(); + } else if (part === "content") { + this.#to("checksum", this.#checksum ? 4 : 0); + } else if (part === "size") { + this.#to("skip", this.#field.readUInt32LE(0)); + } else { + // The end of a frame (its `checksum`, or the data of a skippable one) + if (part === "checksum") { + this.#verify(); + } + this.#decoding = false; + this.#to("magic", 4); + } + } + + /** + * A regular frame, or a skippable one (which the decoder skips), within the + * frames of the request: anything else is invalid (e.g. data after the + * last frame) + */ + #magic(): void { + const magic = this.#field.readUInt32LE(0); + if (magic === ZSTD_MAGIC) { + this.#to("descriptor", 1); + } else if (magic >>> 4 === ZSTD_SKIPPABLE) { + this.#to("size", 4); + } else { + throw formatStack(new Error(ZSTD_INVALID)); + } + this.#budget.frames += 1; + if (this.#budget.frames > ZSTD_MAX_FRAMES) { + throw new BindenError(413, { code: "zstd_frames" }); + } + this.#declared = null; + this.#kept = 0; + this.#keep(4); + } + + /** + * The rest of the header of a frame: `Window_Descriptor` (unless it is a + * single segment) and `Dictionary_ID`, then `Frame_Content_Size` (one byte + * for a single segment of flag `0`, none for another frame of flag `0`). + * The decoder refuses a frame whose reserved bit is set. + */ + #descriptor(): void { + const descriptor = this.#field.readUInt8(0); + if ((descriptor & 0x08) !== 0) { + throw formatStack(new Error(ZSTD_INVALID)); + } + const content = descriptor >>> 6; + this.#single = (descriptor & 0x20) !== 0; + this.#checksum = (descriptor & 0x04) !== 0; + this.#dictionary = dictionarySizes[descriptor & 0x03]; + this.#fcs = content === 0 ? Number(this.#single) : 1 << content; + this.#keep(1); + this.#to("header", (this.#single ? 0 : 1) + this.#dictionary); + } + + /** + * The `Window_Descriptor` (RFC 8878, section 3.1.1.1.2), within 8 MiB, and + * the `Dictionary_ID`, which must be `0`: the parser has no dictionary + */ + #header(): void { + let at = 0; + if (!this.#single) { + const descriptor = this.#field.readUInt8(0); + const base = 2 ** (10 + (descriptor >>> 3)); + this.#window = base + (base / 8) * (descriptor & 0x07); + if (this.#window > ZSTD_WINDOW_MAX) { + throw formatStack(new Error(ZSTD_WINDOW)); + } + at = 1; + } + if ( + this.#dictionary > 0 && + this.#field.readUIntLE(at, this.#dictionary) !== 0 + ) { + throw formatStack(new Error(ZSTD_DICTIONARY)); + } + this.#keep(this.#need); + this.#to("fcs", this.#fcs); + } + + /** + * The declared content size of the frame (RFC 8878, section 3.1.1.1.4), + * read exactly (8 bytes as a `bigint`, 2 bytes with an offset of 256), + * which is the window of a single segment: a frame that declares content + * has a decoder + */ + #size(): void { + const size = this.#fcs; + if (size > 0) { + const declared = + size === 8 + ? this.#field.readBigUInt64LE(0) + : BigInt(this.#field.readUIntLE(0, size) + (size === 2 ? 256 : 0)); + if (this.#single) { + if (declared > BigInt(ZSTD_WINDOW_MAX)) { + throw formatStack(new Error(ZSTD_WINDOW)); + } + this.#window = Number(declared); + } + this.#declared = declared; + } + this.#keep(size); + if (this.#declared !== null && this.#declared > 0n) { + this.#start(); + } + this.#to("block", 3); + } + + /** + * The content of a block: the byte that an RLE block repeats. The decoder + * refuses a block of the reserved type. The first block that is not empty + * (of `Block_Size` 0: a raw or compressed block of no byte, or an RLE block + * that repeats a byte no time) starts the decoder of the frame. Its head + * is therefore never the whole frame: the decoder decodes it as a stream, + * where an empty block produces nothing (in one pass, which it does for a + * whole frame of a known size that it gets at once, an empty compressed + * block is corrupt). + */ + #block(): void { + const header = this.#field.readUIntLE(0, 3); + const type = (header >>> 1) & 0x03; + const size = header >>> 3; + // As the decoder refuses it: a block of the reserved type, or of more bytes + // than the window (an RLE block has 1 byte, and a single segment of no + // content a window of none) + if ( + type === 3 || + (type === 1 ? 1 : size) > Math.min(this.#window, ZSTD_BLOCK_MAX) + ) { + throw formatStack(new Error(ZSTD_INVALID)); + } + this.#last = (header & 0x01) === 1; + if (!this.#decoding && size > 0) { + this.#keep(3); + this.#start(); + } + this.#to("content", type === 1 ? 1 : size); + } + + /** + * The end of a data frame: one without a decoder is empty, and its + * checksum (if any) must be that of an empty content + */ + #verify(): void { + if ( + !this.#decoding && + this.#checksum && + this.#field.readUInt32LE(0) !== ZSTD_EMPTY_CHECKSUM + ) { + throw formatStack(new Error(ZSTD_CHECKSUM)); + } + } + + /** + * Charges the decoder of the frame, then starts it: its window, or its + * declared content size when smaller (what it allocates), and at least + * `ZSTD_DECODER_CHARGE` + */ + #start(): void { + const declared = this.#declared; + const used = + declared !== null && declared < BigInt(this.#window) + ? Number(declared) + : this.#window; + this.#budget.memory += Math.max(ZSTD_DECODER_CHARGE, used); + if (this.#budget.memory > ZSTD_MAX_DECODER_MEMORY) { + throw new BindenError(413, { code: "zstd_frames" }); + } + this.#decoding = true; + this.#started = Buffer.from(this.#head.subarray(0, this.#kept)); + } + + /** Keeps the bytes of the field that has been read in the head of the frame */ + #keep(size: number): void { + this.#field.copy(this.#head, this.#kept, 0, size); + this.#kept += size; + } + + #to(part: IZstdPart, need: number): void { + this.#part = part; + this.#need = need; + this.#have = 0; + } +} + +/** + * Decodes `zstd` data, one frame after the other, through a decoder of + * Node.js for each data frame that is not empty. The frames are read first + * (see `ZstdFrames`): a chunk is given to the decoders only once it is known + * to be valid and within the frames and the decoder memory of the request, + * and each decoder gets the bytes of its frame only (the decoder of Node.js + * 24 stops at the end of the first frame of what it gets, and accepts a + * truncated one). Once a decoder has ended, + * what it has produced is checked against the content size that its frame + * declares (a frame that produces more fails at once), before the next + * frame is decoded. Concatenated frames are one content (skippable frames are + * skipped); no data at all, what is not a frame, what ends within one and a + * frame whose content is not of its declared size fail it. + */ +export class ZstdDecoding extends Duplex { + readonly #frames: ZstdFrames; + /** The decoder of the frame being decoded, and what it has produced */ + #decoder: ZstdDecompress | null = null; + #produced = 0; + /** The declared content size of the frame being decoded, once known */ + #declared: bigint | null = null; + #consumed = 0; + + /** A decoding whose frames count in `budget` (shared by the codings of a request) */ + public constructor(budget: IZstdBudget) { + super({ + write: (chunk: Buffer, _encoding, callback): void => { + this.#write(chunk, callback); + }, + final: (callback): void => { + this.#final(callback); + }, + read: (): void => { + this.#decoder?.resume(); + }, + destroy: (error, callback): void => { + this.#decoder?.destroy(); + callback(error); + }, + }); + this.#frames = new ZstdFrames(budget); + } + + /** The bytes that it has consumed: all of those that it has got */ + public get bytesWritten(): number { + return this.#consumed; + } + + #write(chunk: Buffer, callback: (error?: Error | null) => void): void { + this.#consumed += chunk.byteLength; + const pieces = this.#scan(chunk, callback); + if (pieces !== null) { + this.#feed(pieces, 0, callback); + } + } + + /** + * The pieces of a chunk, read whole before a decoder gets any of it: `null` + * once `callback` has got why it is refused + */ + #scan( + chunk: Buffer, + callback: (error?: Error | null) => void, + ): IZstdPiece[] | null { + try { + return this.#frames.scan(chunk); + } catch (error: unknown) { + callback(error as Error); + return null; + } + } + + /** + * Gives pieces to the decoders in order, each once the decoder has taken + * the previous ones (its backpressure) and the decoder of the previous + * frame has ended, then calls `callback` + */ + #feed( + pieces: readonly IZstdPiece[], + from: number, + callback: (error?: Error | null) => void, + ): void { + for (let index = from; index < pieces.length; index += 1) { + const { bytes, head, end, declared } = pieces[index]; + const decoder = + head === null ? (this.#decoder as ZstdDecompress) : this.#start(head); + this.#declared = declared; + const next = (): void => { + this.#feed(pieces, index + 1, callback); + }; + if (end) { + decoder.once("end", () => { + if (declared !== null && BigInt(this.#produced) !== declared) { + callback(formatStack(new Error(ZSTD_SIZE))); + return; + } + this.#decoder = null; + next(); + }); + decoder.end(bytes); + return; + } else if (!decoder.write(bytes)) { + decoder.once("drain", next); + return; + } + } + callback(); + } + + /** A decoder for a data frame, which gets the head of the frame first */ + #start(head: Buffer): ZstdDecompress { + const decoder = createZstdDecompress({ + params: { [constants.ZSTD_d_windowLogMax]: ZSTD_WINDOW_LOG_MAX }, + }); + this.#decoder = decoder; + this.#produced = 0; + decoder + .on("data", (chunk: Buffer) => { + this.#produced += chunk.byteLength; + if ( + this.#declared !== null && + BigInt(this.#produced) > this.#declared + ) { + this.destroy(formatStack(new Error(ZSTD_SIZE))); + } else if (!this.push(chunk)) { + decoder.pause(); + } + }) + .once("error", (error: Error) => { + this.destroy(error); + }); + decoder.write(head); + return decoder; + } + + #final(callback: (error?: Error | null) => void): void { + if (this.#consumed === 0 || !this.#frames.complete) { + callback(formatStack(new Error(ZSTD_TRUNCATED))); + return; + } + this.push(null); + callback(); + } +} diff --git a/packages/body/test/abort.test.ts b/packages/body/test/abort.test.ts new file mode 100644 index 00000000..4cf5847f --- /dev/null +++ b/packages/body/test/abort.test.ts @@ -0,0 +1,592 @@ +import { Agent, type IncomingMessage, request } from "node:http"; +import { + Binden, + BindenAbortError, + BindenError, + type Context, + type IFunctionMiddleware, + ct_json, + ct_text, +} from "binden"; +import { type IServer, Recorder, listen, never, within } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; +import { gzipSync } from "node:zlib"; +import { once } from "node:events"; + +/** How the parser has settled */ +type IOutcome = { error: unknown } | { ok: true }; + +/** 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 }; +} + +/** 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({ + host: "127.0.0.1", + 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 }; +} + +/** 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(); + } + } + }, + ); + + test( + "a client that goes away once the response has finished", + { timeout: 10_000 }, + async () => { + const parser = new BodyParser(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const logger = new Recorder(); + const server = await listen( + new Binden({ logger }).use(async (context: Context) => { + // Not awaited: the response finishes while the body is read + parser.run(context).then( + () => { + settle({ ok: true }); + }, + (error: unknown) => { + settle({ error }); + }, + ); + await context.send("early"); + }), + ); + try { + const { req, response } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 1000 }, + "x".repeat(10), + ); + const received = await within(response); + received.resume(); + await within(once(received, "end")); + req.destroy(); + ok(isAborted(failure(await within(settled)))); + deepStrictEqual(logger.messages("error"), []); + } finally { + await server.close(); + } + }, + ); + + test( + "a parser that starts once the response has finished and its client has gone away", + { timeout: 10_000 }, + async () => { + const parser = new BodyParser(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const server = await listen( + new Binden().use(async (context: Context) => { + const { socket } = context.request; + const gone = once(socket, "close"); + await context.send("early"); + await gone; + // Only now, once the response has finished + await parser.run(context).then( + () => { + settle({ ok: true }); + }, + (error: unknown) => { + settle({ error }); + }, + ); + }), + ); + try { + // The headers only + const { req, response } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 1000 }, + "", + ); + const received = await within(response); + received.resume(); + await within(once(received, "end")); + req.destroy(); + const error = failure(await within(settled)); + ok(error instanceof BindenError); + deepStrictEqual( + [error.status, error.message], + [500, "The body has been discarded once the response finished"], + ); + } finally { + await server.close(); + } + }, + ); + + test( + "a body received whole is read, also when its connection then closes", + { timeout: 10_000 }, + async () => { + const parser = new BodyParser(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: closed, resolve: close } = + Promise.withResolvers(); + const server = await listen( + new Binden().use(async (context: Context) => { + const { request: incoming } = context; + parser.run(context).then( + () => { + settle({ ok: true }); + }, + (error: unknown) => { + settle({ error }); + }, + ); + // As if the rest of the body had been received, and were still on + // its way to the parser when the connection closes + incoming.complete = true; + incoming.socket.once("close", () => { + close(incoming); + }); + await context.send("early"); + }), + ); + try { + const { req, response } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 10 }, + "first", + ); + const received = await within(response); + received.resume(); + await within(once(received, "end")); + req.destroy(); + const incoming = await within(closed); + incoming.push("-last"); + incoming.push(null); + deepStrictEqual(await within(settled), { ok: true }); + deepStrictEqual(incoming.body, "first-last"); + } finally { + await server.close(); + } + }, + ); + + test("a connection that is kept alive keeps no listener of the parser", async () => { + const parser = new BodyParser(); + const counts: number[] = []; + const server = await listen( + new Binden().use(async (context: Context) => { + await parser.run(context); + counts.push(context.request.socket.listenerCount("close")); + await context.send(String(context.request.body)); + }), + ); + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + try { + for (let index = 0; index < 3; index += 1) { + const sent = request({ + host: "127.0.0.1", + port: server.port, + method: "POST", + agent, + headers: { "Content-Type": ct_text }, + }); + sent.end("body"); + const [received] = (await within(once(sent, "response"))) as [ + IncomingMessage, + ]; + received.resume(); + await within(once(received, "end")); + deepStrictEqual(sent.reusedSocket, index > 0); + } + deepStrictEqual(new Set(counts).size, 1); + } finally { + agent.destroy(); + await server.close(); + } + }); + + 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( + "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 }, + async () => { + 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(); + } + }, + ); + + 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"); + }); + 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(); + } + }, + ); + + 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(); + } + }, + ); + + test( + "a request stream that closes before its end, without an error, while the client waits", + { timeout: 10_000 }, + async () => { + const watched = watch(new BodyParser(), (context) => { + setTimeout(() => { + context.request.emit("close"); + }, 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, null); + req.destroy(); + } finally { + await server.close(); + } + }, + ); + + 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(); + } + }, + ); +}); + +suite("BodyParser (once the response has finished)", () => { + const discarded = "The body has been discarded once the response finished"; + + test( + "a parser that starts once the response has finished refuses the body", + { timeout: 10_000 }, + async () => { + for (const [type, payload] of [ + [ct_json, '{"a":1}'], + [ct_text, "hello"], + ["application/octet-stream", "hello"], + ] as const) { + const watched = watch( + new BodyParser({ raw: true }), + async (context) => { + if (!context.response.writableFinished) { + await once(context.response, "finish"); + } + }, + ); + const logger = new Recorder(); + const server = await listen( + new Binden({ logger }).use(async (context: Context) => { + // Without `done`: the next middleware runs + await context.response.status(202).send("accepted"); + }, watched.middleware), + ); + try { + // The body once the client has the reply + const { req, response } = upload( + server, + { "Content-Type": type, "Content-Length": payload.length }, + "", + ); + const received = await within(response); + received.resume(); + await within(once(received, "end")); + req.end(payload); + const error = failure(await within(watched.settled)); + ok(error instanceof BindenError); + deepStrictEqual([error.status, error.message], [500, discarded]); + ok(logger.messages("error").includes(discarded)); + } finally { + await server.close(); + } + } + }, + ); + + test( + "a parser that starts once the response has ended, but not finished, reads the body", + { + // Windows takes a whole write while its send buffer has room: the + // response may finish before the client reads it + skip: process.platform === "win32", + timeout: 10_000, + }, + async () => { + // Larger than what the connection buffers: the response cannot finish + // before the client reads it + const large = Buffer.alloc(32 * 1024 * 1024, 0x61); + const states: boolean[] = []; + const watched = watch(new BodyParser(), (context) => { + const { response } = context; + states.push(response.writableEnded, response.writableFinished); + }); + const server = await listen( + new Binden().use((context: Context) => { + context.response.end(large); + }, watched.middleware), + ); + try { + const { req, response } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 5 }, + "", + ); + const context = await within(watched.started); + req.end("hello"); + deepStrictEqual(await within(watched.settled), { ok: true }); + deepStrictEqual( + [states, context.request.body], + [[true, false], "hello"], + ); + const received = await within(response); + received.resume(); + await within(once(received, "end")); + } 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..214999ef --- /dev/null +++ b/packages/body/test/codings.test.ts @@ -0,0 +1,308 @@ +import { type IBody, type IReply, send, serve, within } from "./helpers.js"; +import { type IncomingMessage, request } from "node:http"; +import { + brotliCompressSync, + constants, + createZstdCompress, + deflateSync, + gzipSync, + zstdCompressSync, +} from "node:zlib"; +import { ct_json, ct_text } from "binden"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; +import { once } from "node:events"; + +/** 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 }; + +/** 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(); + 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, foo", + "compress", + "zstd", + ]) { + const body = coding === "zstd" ? zstdCompressSync(hello) : hello; + deepStrictEqual( + await post(parser, body, text(coding)), + { status: 415, connection: "close", body: null }, + coding, + ); + } + }); + + test("`identity` is no coding", async () => { + const parser = new BodyParser(); + for (const [coding, body] of [ + ["identity", hello], + ["IDENTITY", hello], + [" identity , identity ", hello], + ["gzip, identity", gzipSync(hello)], + ["identity, br", brotliCompressSync(hello)], + ] as const) { + deepStrictEqual( + await post(parser, body, text(coding)), + { status: 200, connection: "keep-alive", body: parsed }, + coding, + ); + } + }); + + 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); + 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("data after the end of a coding is a 400", async () => { + const parser = new BodyParser({ zstd: true }); + const junk = Buffer.from("junk"); + const zeros = Buffer.alloc(16); + for (const [coding, body] of [ + ["deflate", Buffer.concat([deflateSync(hello), junk])], + ["deflate", Buffer.concat([deflateSync(hello), deflateSync(hello)])], + ["br", Buffer.concat([brotliCompressSync(hello), junk])], + ["gzip", Buffer.concat([gzipSync(hello), junk])], + // Padding, which the decoder of Node.js would ignore + ["gzip", Buffer.concat([gzipSync(hello), zeros])], + ["zstd", Buffer.concat([zstdCompressSync(hello), zeros])], + // After the end of the first coding to remove + [ + "deflate, br", + brotliCompressSync(Buffer.concat([deflateSync(hello), junk])), + ], + // After the end of the second one + [ + "deflate, br", + Buffer.concat([brotliCompressSync(deflateSync(hello)), junk]), + ], + ] as const) { + deepStrictEqual( + await post(parser, body, text(coding)), + { status: 400, connection: "close", body: null }, + coding, + ); + } + }); + + test("concatenated `gzip` members and `zstd` frames are one body", async () => { + const parser = new BodyParser({ zstd: true }); + const twice = { type: "text", value: hello.repeat(2) }; + const members = Buffer.concat([gzipSync(hello), gzipSync(hello)]); + deepStrictEqual((await post(parser, members, text("gzip"))).body, twice); + // Also on Node.js 24, whose decoder decodes only the first frame + const frames = Buffer.concat([ + zstdCompressSync(hello), + zstdCompressSync(hello), + ]); + deepStrictEqual((await post(parser, frames, text("zstd"))).body, twice); + }); + + test( + "the reading stops once a coding has ended, also with `verify_encoded`", + { + // Windows resets a connection closed with unread data at once: the client + // may lose the reply before it reads it + skip: process.platform === "win32", + timeout: 20_000, + }, + async () => { + for (const coding of ["deflate", "br"]) { + let verified = false; + const parser = new BodyParser({ + limit: 1024, + verify_encoded: true, + verify: (): void => { + verified = true; + }, + }); + const server = await serve(parser); + // An endless body (until the reply), which the client stops at `cap` + const cap = 64 * 1024 * 1024; + const chunk = Buffer.alloc(64 * 1024, 0x20); + const client = request({ + host: "127.0.0.1", + port: server.port, + method: "POST", + agent: false, + headers: text(coding), + }); + // The server closes the connection after its reply: a client that is + // still writing may get the reset before it reads the reply (TCP) + let reset = false; + const closed = new Promise((resolve) => { + client.once("error", () => { + reset = true; + resolve(null); + }); + }); + let replied = false; + const response = new Promise((resolve) => { + client.once("response", (reply: IncomingMessage) => { + replied = true; + resolve(reply); + }); + }); + const writing = (): boolean => !replied && !reset; + try { + client.write( + coding === "br" ? brotliCompressSync(hello) : deflateSync(hello), + ); + let sent = 0; + while (writing() && sent < cap) { + sent += chunk.byteLength; + if (!client.write(chunk)) { + await Promise.race([once(client, "drain"), response, closed]); + } + } + const reply = await within(Promise.race([response, closed])); + // The reading has stopped, without the verification, whether the + // reply has reached the client or the connection has been reset + ok(sent < cap, `${sent} bytes sent`); + deepStrictEqual(verified, false, coding); + if (reply !== null) { + reply.resume(); + deepStrictEqual( + [reply.statusCode, reply.headers.connection], + [400, "close"], + coding, + ); + } + } finally { + client.destroy(); + await server.close(); + } + } + }, + ); + + 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-context.ts b/packages/body/test/fixtures/late-context.ts new file mode 100644 index 00000000..c9d92005 --- /dev/null +++ b/packages/body/test/fixtures/late-context.ts @@ -0,0 +1,93 @@ +// Run by `process.test.ts`, without handlers of the process: what a context +// that only looks like one throws once the parse has started (its response, +// its signal) must be the rejection of the parse, never an uncaught exception +// that crashes the process, and the parse must settle +import { Binden, type Context } from "binden"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { BodyParser } from "../../index.js"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +/** A proxy of `target` whose `name` is a method that throws */ +function throwing(target: T, name: string): T { + return new Proxy(target, { + get(object, key): unknown { + if (key === name) { + return (): never => { + throw new Error(`${name} of the context`); + }; + } + const value: unknown = Reflect.get(object, key, object); + return typeof value === "function" + ? (value as () => unknown).bind(object) + : value; + }, + }); +} + +const settled: string[] = []; +const parser = new BodyParser({ limit: 4 }); +const app = new Binden().use(async (context: Context) => { + // The response, or the signal + const [key, target, name] = + context.request.url === "/response" + ? ["response", context.response, "setHeader"] + : ["signal", new AbortController().signal, "removeEventListener"]; + const replaced = throwing(target, name); + const ducked = new Proxy(context, { + get(object, property): unknown { + return property === key + ? replaced + : (Reflect.get(object, property, object) as unknown); + }, + }); + try { + await parser.run(ducked); + settled.push("resolved"); + } catch (error: unknown) { + settled.push((error as Error).message); + } + await context.send("done"); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +// Over the limit once it is read (chunked), then a body within it +for (const [path, chunks] of [ + ["/response", ["ab", "cdefgh"]], + ["/signal", ["ab"]], +] as const) { + const sent = request({ + agent: false, + host: "127.0.0.1", + port, + path, + method: "POST", + headers: { "Content-Type": "text/plain" }, + }); + sent.on("error", () => { + // The connection may close before the body is sent + }); + const response = new Promise((resolve) => { + sent.once("response", resolve); + }); + for (const chunk of chunks) { + sent.write(chunk); + } + sent.end(); + const received = await response; + received.resume(); + await once(received, "end"); + statuses.push(received.statusCode ?? 0); +} + +server.close(); +process.stdout.write(`${statuses.join(" ")} | ${settled.join(" | ")}\n`); diff --git a/packages/body/test/fixtures/late-rejections.ts b/packages/body/test/fixtures/late-rejections.ts new file mode 100644 index 00000000..12cc81db --- /dev/null +++ b/packages/body/test/fixtures/late-rejections.ts @@ -0,0 +1,79 @@ +// 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 { Binden, BindenError } from "binden"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { BodyParser } from "../../index.js"; +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 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("/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", + new BodyParser({ + type: (): "json" => + Promise.reject(new Error("A failure")) as unknown as "json", + }), + ); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const path of ["/verify", "/verify-binden", "/parse", "/type"]) { + const response = await new Promise((resolve, reject) => { + request( + { + agent: false, + path, + host: "127.0.0.1", + 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/fixtures/zstd-concurrent.ts b/packages/body/test/fixtures/zstd-concurrent.ts new file mode 100644 index 00000000..94f0ee7c --- /dev/null +++ b/packages/body/test/fixtures/zstd-concurrent.ts @@ -0,0 +1,111 @@ +// Run by `zstd-frames.test.ts`: 20 concurrent requests of the most costly +// `zstd` bodies within the limits, through a parser of `zstd`: +// empty 1024 empty frames that each declare a window of 8 MiB (no decoder) +// windows 256 frames of 1 byte that each declare a window of 8 MiB and their +// size (the least charge each: 256 decoders) +// rle 256 frames of an RLE block of 1 byte (256 decoders) +// Prints the statuses of the replies, the zlib handles (the decoders of +// Node.js) created for them, the CPU time per request (in ms), the garbage +// collections meanwhile, and how much the peak memory of the process grows +// (in MiB). +import { Binden, type Context } from "binden"; +import type { AddressInfo } from "node:net"; +import { BodyParser } from "../../index.js"; +import { PerformanceObserver } from "node:perf_hooks"; +import { createHook } from "node:async_hooks"; +import { once } from "node:events"; +import { request } from "node:http"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +const magic = [0x28, 0xb5, 0x2f, 0xfd]; +// A window of 8 MiB (`Exponent` 13), and of 1 KiB +const large = 13 << 3; +const frames: Record = { + // The empty last block (raw, of no byte) + empty: [1024, [...magic, 0, large, 1, 0, 0]], + // A `Frame_Content_Size` of 4 bytes (1), then an RLE block of 1 byte + windows: [256, [...magic, 0x80, large, 1, 0, 0, 0, 0x0b, 0, 0, 0x61]], + rle: [256, [...magic, 0, 0, 0x0b, 0, 0, 0x61]], +}; +const [count, frame] = frames[process.argv[2] ?? ""] ?? [0, []]; +const body = Buffer.concat( + Array.from({ length: count }, () => Buffer.from(frame)), +); + +const server = new Binden() + .use(new BodyParser({ zstd: true, raw: true }), (context: Context) => + context.json({ size: (context.request.body as Buffer).length }), + ) + .createServer(); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +/** Sends the body: the status of the reply */ +function post(): Promise { + return new Promise((resolve, reject) => { + const client = request( + { + host: "127.0.0.1", + port, + method: "POST", + agent: false, + headers: { + "Content-Type": "application/octet-stream", + "Content-Encoding": "zstd", + "Content-Length": body.length, + }, + }, + (response) => { + response.resume(); + response.once("end", () => { + resolve(response.statusCode); + }); + }, + ); + client.once("error", reject); + client.end(body); + }); +} + +// Warmed up +await Promise.all([post(), post()]); +let gcs = 0; +const observer = new PerformanceObserver((list) => { + gcs += list.getEntries().length; +}); +observer.observe({ entryTypes: ["gc"] }); +// What the load of the machine does not change: the decoders created +let zlib = 0; +const hook = createHook({ + init(_id: number, type: string): void { + if (type === "ZLIB") { + zlib += 1; + } + }, +}).enable(); +const before = process.resourceUsage().maxRSS; +const cpu = process.cpuUsage(); +const statuses = await Promise.all(Array.from({ length: 20 }, post)); +const { user, system } = process.cpuUsage(cpu); +hook.disable(); +const growth = Math.round((process.resourceUsage().maxRSS - before) / 1024); +// The entries of the last collections are delivered asynchronously +await new Promise((resolve) => { + setTimeout(resolve, 20); +}); +observer.disconnect(); +server.close(); +process.stdout.write( + `${JSON.stringify({ + statuses: [...new Set(statuses)], + zlib, + cpu: Math.round((user + system) / 1000 / 20), + gcs, + growth, + })}\n`, +); diff --git a/packages/body/test/fixtures/zstd-decoders.ts b/packages/body/test/fixtures/zstd-decoders.ts new file mode 100644 index 00000000..ffcb21e7 --- /dev/null +++ b/packages/body/test/fixtures/zstd-decoders.ts @@ -0,0 +1,51 @@ +// Run by `zstd-frames.test.ts`: the most decoders that a request may cost +// (`ZSTD_MAX_DECODER_MEMORY`), each by a decoder of its own, of frames that +// each declare the largest window (8 MiB) and decode an RLE block of 128 KiB: +// `decoders` is 256 frames that declare their size (the least charge each), +// `windows` 8 frames that declare none (the charge of their window each). +// Prints the size of the content, the time that it takes and how much the +// peak memory of the process grows (in MiB). +import { + ZSTD_DECODER_CHARGE, + ZSTD_MAX_DECODER_MEMORY, + ZstdDecoding, +} from "../../src/zstd.js"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 20_000).unref(); + +const declared = process.argv[2] === "decoders"; +const size = 128 * 1024; +const block = Buffer.alloc(4); +// The last block, of type RLE: 128 KiB of `a` +block.writeUIntLE((size << 3) | (1 << 1) | 1, 0, 3); +block.writeUInt8(0x61, 3); +// A window of 8 MiB (`Exponent` 13), and a `Frame_Content_Size` of 4 bytes +const fcs = Buffer.alloc(declared ? 4 : 0); +if (declared) { + fcs.writeUInt32LE(size); +} +const frame = Buffer.concat([ + Buffer.from([0x28, 0xb5, 0x2f, 0xfd, declared ? 0x80 : 0, 13 << 3]), + fcs, + block, +]); +const count = + ZSTD_MAX_DECODER_MEMORY / (declared ? ZSTD_DECODER_CHARGE : 8 * 1024 * 1024); +const body = Buffer.concat(Array.from({ length: count }, () => frame)); + +const before = process.resourceUsage().maxRSS; +const started = performance.now(); +const decoding = new ZstdDecoding({ frames: 0, memory: 0 }); +let decoded = 0; +decoding.on("data", (chunk: Buffer) => { + decoded += chunk.byteLength; +}); +decoding.end(body); +await once(decoding, "end"); +const ms = Math.round(performance.now() - started); +const growth = Math.round((process.resourceUsage().maxRSS - before) / 1024); +process.stdout.write(`${JSON.stringify({ size: decoded, ms, growth })}\n`); diff --git a/packages/body/test/formatted.test.ts b/packages/body/test/formatted.test.ts new file mode 100644 index 00000000..6f6ed704 --- /dev/null +++ b/packages/body/test/formatted.test.ts @@ -0,0 +1,138 @@ +import { BindenRequest, type Context } from "binden"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; +import { Socket } from "node:net"; + +/** An error of the application's own (e.g. of a getter, or of a trap) */ +class OwnError extends Error {} + +/** What a call has thrown (or rejected with), and whether it was formatted then */ +interface IOutcome { + readonly thrown: unknown; + readonly formatted: boolean; +} + +/** + * Runs a call while `Error.prepareStackTrace` records each error whose stack + * V8 formats: what it throws (or its promise rejects with), and whether that + * was formatted before the caller got it (`null` when it does not fail) + */ +async function outcome(run: () => unknown): Promise { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + await run(); + return null; + } catch (thrown: unknown) { + return { + thrown, + formatted: typeof thrown === "object" && formatted.has(thrown as object), + }; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A revoked proxy */ +function revoked(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** A logger that logs nothing */ +const silent = { + fatal: (): null => null, + error: (): null => null, + warn: (): null => null, + info: (): null => null, + debug: (): null => null, + trace: (): null => null, + child: (): unknown => silent, +}; + +suite( + "The entry points of the body parser format what they throw (round 27)", + () => { + test("new BodyParser(): anything but an object is refused, and what reading the options throws is formatted", async () => { + for (const [options, message] of [ + [null, "`options` is not an object: null"], + ["json", "`options` is not an object: json"], + [Symbol("s"), "`options` is not an object: (a symbol)"], + ] as const) { + const failed = await outcome(() => new BodyParser(options as never)); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + [message, true], + ); + } + const failed = await outcome(() => new BodyParser(revoked())); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + for (const make of [ + (own: Error): object => ({ + get limit(): number { + throw own; + }, + }), + // Read by the validation of `type` + (own: Error): object => ({ + type: new Proxy([], { + get(): never { + throw own; + }, + }), + }), + ]) { + const own = new OwnError("own"); + deepStrictEqual( + await outcome(() => new BodyParser(make(own) as never)), + { thrown: own, formatted: true }, + ); + } + }); + + test("an entry of `type` that is not a string is described without converting it", async () => { + let conversions = 0; + const entry = { + toString(): string { + conversions += 1; + return "application/json"; + }, + }; + const failed = await outcome( + () => new BodyParser({ type: [entry as unknown as string] }), + ); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted, conversions], + ["Invalid media type in `type`: (an object)", true, 0], + ); + }); + + test("run() formats what reading its context throws, also what Node.js throws for what it gives (e.g. its signal)", async () => { + const request = new BindenRequest(new Socket()); + request.method = "POST"; + request.headers = { "content-type": "application/json" }; + const signalless = { + request, + response: { headersSent: false, writableFinished: false }, + signal: "not a signal", + log: silent, + }; + for (const context of [null, revoked(), {}, signalless]) { + const failed = await outcome(() => + new BodyParser().run(context as unknown as Context), + ); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + } + }); + }, +); diff --git a/packages/body/test/helpers.ts b/packages/body/test/helpers.ts new file mode 100644 index 00000000..463ab66d --- /dev/null +++ b/packages/body/test/helpers.ts @@ -0,0 +1,187 @@ +import { Agent, request } from "undici"; +import { Binden, type Context, type IMiddleware } from "binden"; +import type { AddressInfo } from "node:net"; +import type { ILogger } from "@binden/logger"; +import { Readable } from "node:stream"; +import type { Server } from "node:http"; +import { once } from "node:events"; + +/** 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, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${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(10_000), + }); + 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. The + * default only guards against a hang, with a deadline that a loaded machine + * does not reach: a test that asserts a timing passes its own + */ +export async function within(promise: Promise, ms = 10_000): 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 new file mode 100644 index 00000000..3d498d22 --- /dev/null +++ b/packages/body/test/index.test.ts @@ -0,0 +1,572 @@ +import { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; +import { + BindenError, + type ContentType, + type Context, + ct_form, + ct_json, + ct_text, +} from "binden"; +import { BodyParser, type IParse } from "../index.js"; +import { + type IBody, + type IReply, + chunked, + send, + serve, + summarize, +} from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { once } from "node:events"; + +/** 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(); + } +} + +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: [] }), + ); + }); + + test("JSON: an empty body has no value", async () => { + deepStrictEqual(await post([new BodyParser()], "", json), { + status: 200, + connection: "keep-alive", + body: { type: "undefined" }, + }); + }); + + 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); + } + }); + + 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("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("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("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, + }); + const without: unknown[] = []; + const run = async (context: Context): Promise => { + try { + await undefinedParse.run(context); + } catch (failure: unknown) { + without.push(failure); + throw failure; + } + }; + deepStrictEqual((await post([run], "{}", json)).status, 400); + deepStrictEqual((without[0] as Error).message, "Bad Request"); + }); + + test("JSON: the failures of a custom `parse`", async () => { + const unprocessable = (): never => { + throw new BindenError(422); + }; + 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("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"]); + }); + + 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", + }, + ); + }); + + test("charset", async () => { + const parser = new BodyParser(); + const text = (charset: string, body: Buffer): Promise => + post([parser], body, { + "Content-Type": `${ct_text}; charset=${charset}`, + }); + + 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, + ); + }); + + 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("`parameter_limit`", async () => { + const form = { "Content-Type": ct_form }; + const parameters = (count: number): string => + Array.from({ length: count }, (_, index) => `p${index}=${index}`).join( + "&", + ); + + // 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 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); + + 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); + + const unlimited = new BodyParser({ parameter_limit: Infinity }); + deepStrictEqual( + (await post([unlimited], parameters(1001), form)).status, + 200, + ); + }); + + 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("`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" }], + ]; + for (const [type, summary] of kinds) { + deepStrictEqual( + (await post([all], "[]", { "Content-Type": type })).body, + summary, + type, + ); + } + + 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("`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], + ); + + // Not called without a `Content-Type` + deepStrictEqual((await post([parser], "[]", {})).body, { + type: "undefined", + }); + deepStrictEqual(received.length, 2); + }); + + 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("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("")), + ); + }; + 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("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" }, + ); + }); + + 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("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 other = new BodyParser(); + deepStrictEqual((await post([parser, other, parser], "[1]", json)).body, { + type: "json", + value: [1], + }); + // An empty body (without a value) is not read again either + deepStrictEqual((await post([parser, other], "", json)).body, { + type: "undefined", + }); + deepStrictEqual(inputs, ["[1]"]); + }); + + 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/kept-failures.test.ts b/packages/body/test/kept-failures.test.ts new file mode 100644 index 00000000..bf930f85 --- /dev/null +++ b/packages/body/test/kept-failures.test.ts @@ -0,0 +1,65 @@ +import { Binden, BindenError, type Context, ct_json } from "binden"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { echo, listen, send } from "./helpers.js"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; + +/** + * Sends a JSON body through a parser of these options, in an application + * whose `error_handler` replies with the status of what it gets: that + * error, and the status of the reply + */ +async function handled( + options: ConstructorParameters[0], +): Promise<{ readonly error: unknown; readonly status: number }> { + const errors: unknown[] = []; + const server = await listen( + new Binden({ + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + errors.push(error); + context.status(error instanceof BindenError ? error.status : 599); + await context.send(); + }, + }).use(new BodyParser(options), echo), + ); + try { + const { status } = await send(server.url, "[1]", { + "Content-Type": ct_json, + }); + deepStrictEqual(errors.length, 1); + return { error: errors[0], status }; + } finally { + await server.close(); + } +} + +suite("BodyParser (failures of the options that it calls)", () => { + test( + "a failure of `verify`, `parse` or `type` that is not a BindenError is a BindenError (500) of it for the `error_handler`, never the failure itself", + { timeout: 30_000 }, + async () => { + const failure = new TypeError("A failure"); + const fail = (): never => { + throw failure; + }; + for (const options of [ + { verify: fail }, + { verify: (): Promise => Promise.reject(failure) }, + { parse: fail }, + { parse: (): Promise => Promise.reject(failure) }, + { type: fail }, + ]) { + const { error, status } = await handled(options); + ok(error instanceof BindenError, Object.keys(options).join()); + deepStrictEqual( + { status, error: error.status, cause: error.cause === failure }, + { status: 500, error: 500, cause: true }, + Object.keys(options).join(), + ); + } + }, + ); +}); diff --git a/packages/body/test/late-calls.test.ts b/packages/body/test/late-calls.test.ts new file mode 100644 index 00000000..0e57e736 --- /dev/null +++ b/packages/body/test/late-calls.test.ts @@ -0,0 +1,483 @@ +import { Binden, BindenError, type Context, ct_json, ct_text } from "binden"; +import { type IServer, Recorder, listen, within } from "./helpers.js"; +import { type IncomingMessage, request } from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; +import { once } from "node:events"; + +/** An error of the application's own (e.g. of a getter, or of a method) */ +class OwnError extends Error {} + +/** How a parse has settled: its error, and whether it was formatted once `run()` rejected */ +type ISettled = + | { readonly error: unknown; readonly formatted: boolean } + | { readonly ok: true }; + +/** The getters that replace properties of a proxy (see `ducked()`) */ +type IReplaced = Readonly unknown>>; + +/** + * A proxy that only looks like `target`: it reads the properties of the + * target (its methods bound to it) and sets them on it, but for the getters + * of `replaced`, which may succeed first and throw later + */ +function ducked(target: T, replaced: IReplaced): T { + return new Proxy(target, { + get(object, key): unknown { + if (typeof key === "string" && Object.hasOwn(replaced, key)) { + return replaced[key](); + } + const value: unknown = Reflect.get(object, key, object); + return typeof value === "function" + ? (value as (...args: unknown[]) => unknown).bind(object) + : value; + }, + set(object, key, value): boolean { + return Reflect.set(object, key, value, object); + }, + }); +} + +/** + * A proxy of `target` (and of each object that it gives, e.g. the request of + * a context) whose every use throws an error of its own (kept in `errors`) + * once `armed()` is true: its methods are bound to their objects + */ +function deep( + target: T, + errors: OwnError[], + armed: () => boolean, +): T { + const proxies = new WeakMap(); + const fail = (): never => { + const error = new OwnError("A late use of the context"); + errors.push(error); + throw error; + }; + const handler: ProxyHandler = { + get(object, key): unknown { + if (armed()) { + return fail(); + } + const value: unknown = Reflect.get(object, key, object); + if (typeof value === "function") { + return (value as (...args: unknown[]) => unknown).bind(object); + } else if (typeof value !== "object" || value === null) { + return value; + } + const proxy = proxies.get(value) ?? new Proxy(value, handler); + proxies.set(value, proxy); + return proxy; + }, + set(object, key, value): boolean { + return armed() ? fail() : Reflect.set(object, key, value, object); + }, + }; + return new Proxy(target, handler) as T; +} + +/** + * A function (a getter, or a method) that throws a new error of its own, + * kept in `errors`, from its call `from` on + */ +function failing(errors: OwnError[], name: string, from = 1): () => unknown { + let calls = 0; + return (): unknown => { + calls += 1; + if (calls >= from) { + const error = new OwnError(`${name} of the context`); + errors.push(error); + throw error; + } + return null; + }; +} + +/** The getter of a method (see `ducked()`) */ +function method(call: () => unknown): () => unknown { + return () => call; +} + +/** + * Serves a parser that runs on a context that only looks like the one of the + * request (made by `duck`): how it settles, while `Error.prepareStackTrace` + * records the errors whose stacks V8 formats. With `early`, the reply is sent + * first (the parse is not awaited). + */ +async function serveDucked( + parser: BodyParser, + duck: (context: Context) => Context, + early = false, +): Promise<{ + server: IServer; + started: Promise; + settled: Promise; + restore(): void; +}> { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + const { promise: started, resolve: start } = Promise.withResolvers(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const run = (context: Context): Promise => + parser.run(duck(context)).then( + () => { + settle({ ok: true }); + }, + (error: unknown) => { + settle({ error, formatted: formatted.has(error as object) }); + throw error; + }, + ); + const server = await listen( + new Binden({ logger: new Recorder() }).use(async (context: Context) => { + start(context); + if (early) { + run(context).catch(() => { + // Settled above + }); + await context.send("early"); + } else { + await run(context); + await context.send("parsed"); + } + }), + ); + return { + server, + started, + settled, + restore: (): void => { + Reflect.set(Error, "prepareStackTrace", previous); + }, + }; +} + +/** Sends the headers and the first chunk of a body, without ending it */ +function upload( + server: IServer, + headers: Record, + chunk: string, +): { req: ReturnType; response: Promise } { + const req = request({ + host: "127.0.0.1", + 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.on("error", () => { + // The server may close the connection before the body is sent + }); + req.write(chunk); + return { req, response }; +} + +/** The status of the reply to a request */ +async function status(response: Promise): Promise { + const received = await within(response); + received.resume(); + await within(once(received, "end")); + return received.statusCode ?? 0; +} + +suite("BodyParser (late calls of a context)", () => { + test("a context whose getters throw from their second read: each is read once", async () => { + for (const [parser, headers, chunks, expected] of [ + [new BodyParser(), { "Content-Type": ct_json }, ['{"a":', "1}"], 200], + // Refused before the body is read, and while it is read + [ + new BodyParser({ limit: 4 }), + { "Content-Type": ct_text, "Content-Length": 10 }, + ["abcdefghij"], + 413, + ], + [ + new BodyParser({ limit: 4 }), + { "Content-Type": ct_text }, + ["ab", "cdefgh"], + 413, + ], + ] as const) { + const reads = new Map(); + const served = await serveDucked(parser, (context) => { + const replaced = Object.fromEntries( + ["request", "response", "signal", "log", "setHeader"].map((key) => [ + key, + (): unknown => { + const count = (reads.get(key) ?? 0) + 1; + reads.set(key, count); + if (count > 1) { + throw new OwnError(`${key} read again`); + } + const value: unknown = Reflect.get(context, key, context); + return typeof value === "function" + ? (value as () => unknown).bind(context) + : value; + }, + ]), + ); + return ducked(context, replaced); + }); + try { + const { req, response } = upload(served.server, headers, chunks[0]); + const context = await within(served.started); + req.end(chunks[1]); + deepStrictEqual(await status(response), expected); + const settled = await within(served.settled); + if (expected === 200) { + deepStrictEqual(settled, { ok: true }); + deepStrictEqual(context.request.body, { a: 1 }); + } else { + ok("error" in settled && settled.error instanceof BindenError); + deepStrictEqual(settled.error.status, 413); + } + ok([...reads.values()].every((count) => count === 1)); + } finally { + served.restore(); + await served.server.close(); + } + } + }); + + test("a context whose every use throws once run() has returned (also of its request, response, signal and logger): the first error is the rejection, formatted, never an uncaught one", async () => { + const errors: OwnError[] = []; + let armed = false; + const served = await serveDucked( + new BodyParser({ limit: 4 }), + (context) => { + setImmediate(() => { + armed = true; + }); + return deep(context, errors, () => armed); + }, + ); + try { + const { req, response } = upload( + served.server, + { "Content-Type": ct_text }, + "ab", + ); + const context = await within(served.started); + const closed = once(context.request, "close"); + await new Promise((resolve) => { + setImmediate(resolve); + }); + req.write("cdefgh"); + const settled = await within(served.settled); + ok(errors.length > 0); + deepStrictEqual(settled, { error: errors[0], formatted: true }); + req.end(); + deepStrictEqual(await status(response), 500); + // What still listens to the request (e.g. as it closes) uses it no more + context.request.destroy(); + await within(closed); + await new Promise((resolve) => { + setImmediate(resolve); + }); + } finally { + served.restore(); + await served.server.close(); + } + }); + + test("a response whose setHeader() throws once a chunked body is over the limit: its error is the rejection, formatted, and the request is unpiped", async () => { + const errors: OwnError[] = []; + let listeners = 0; + const served = await serveDucked( + new BodyParser({ limit: 4 }), + (context) => { + listeners = context.request.socket.listenerCount("close"); + return ducked(context, { + response: () => + ducked(context.response, { + setHeader: method(failing(errors, "setHeader")), + }), + }); + }, + ); + try { + const { req, response } = upload( + served.server, + { "Content-Type": ct_text }, + "ab", + ); + const context = await within(served.started); + const { socket } = context.request; + req.write("cdefgh"); + const settled = await within(served.settled); + deepStrictEqual(settled, { error: errors[0], formatted: true }); + deepStrictEqual( + [context.request.readableFlowing, socket.listenerCount("close")], + [false, listeners], + ); + req.end(); + deepStrictEqual(await status(response), 500); + } finally { + served.restore(); + await served.server.close(); + } + }); + + test("a signal whose reason throws once it is aborted, then a setHeader() that throws: the first failure is the rejection", async () => { + const errors: OwnError[] = []; + const controller = new AbortController(); + const served = await serveDucked(new BodyParser(), (context) => + ducked(context, { + signal: () => + ducked(controller.signal, { reason: failing(errors, "reason") }), + response: () => + ducked(context.response, { + setHeader: method(failing(errors, "setHeader")), + }), + }), + ); + try { + const { req, response } = upload( + served.server, + { "Content-Type": ct_text, "Content-Length": 10 }, + "ab", + ); + await within(served.started); + controller.abort(); + const settled = await within(served.settled); + deepStrictEqual( + [settled, errors.map(({ message }) => message)], + [ + { error: errors[0], formatted: true }, + ["reason of the context", "setHeader of the context"], + ], + ); + req.end("cdefghij"); + deepStrictEqual(await status(response), 500); + } finally { + served.restore(); + await served.server.close(); + } + }); + + test("a cleanup that throws once the body is read (e.g. a signal whose removeEventListener() throws), also the one of `verify`: the rejection", async () => { + for (const from of [1, 2]) { + const errors: OwnError[] = []; + const controller = new AbortController(); + let verified = 0; + const verify = (): void => { + verified += 1; + }; + const served = await serveDucked( + new BodyParser(from === 1 ? {} : { verify }), + (context) => + ducked(context, { + signal: () => + ducked(controller.signal, { + removeEventListener: method( + failing(errors, "removeEventListener", from), + ), + }), + }), + ); + try { + const { req, response } = upload( + served.server, + { "Content-Type": ct_text }, + "ab", + ); + const context = await within(served.started); + req.end("cd"); + const settled = await within(served.settled); + deepStrictEqual( + [settled, verified, typeof context.request.body], + [{ error: errors[0], formatted: true }, from - 1, "undefined"], + ); + deepStrictEqual(await status(response), 500); + } finally { + served.restore(); + await served.server.close(); + } + } + }); + + test("a logger that throws once the body is read (e.g. as `verify` refuses it) is ignored: the rejection is the refusal", async () => { + const errors: OwnError[] = []; + const debug = failing(errors, "debug"); + const logger = { + fatal: (): null => null, + error: (): null => null, + warn: (): null => null, + info: (): null => null, + debug, + trace: (): null => null, + child: (): unknown => logger, + }; + const served = await serveDucked( + new BodyParser({ + verify: (): never => { + throw new BindenError(401); + }, + }), + (context) => + ducked(context, { + log: () => logger, + }), + ); + try { + const { req, response } = upload( + served.server, + { "Content-Type": ct_text }, + "ab", + ); + req.end(); + const { error } = (await within(served.settled)) as { error: unknown }; + // Its calls never fail (see the logging of binden): it has thrown, and + // the parse has gone on + ok(error instanceof BindenError && errors.length > 0); + deepStrictEqual(await status(response), 401); + } finally { + served.restore(); + await served.server.close(); + } + }); + + test("a request whose `complete` throws once its client goes away after the reply: the rejection", async () => { + const errors: OwnError[] = []; + const served = await serveDucked( + new BodyParser(), + (context) => + ducked(context, { + request: () => + ducked(context.request, { + complete: failing(errors, "complete"), + }), + }), + true, + ); + try { + const { req, response } = upload( + served.server, + { "Content-Type": ct_text, "Content-Length": 1000 }, + "x".repeat(10), + ); + deepStrictEqual(await status(response), 200); + req.destroy(); + deepStrictEqual(await within(served.settled), { + error: errors[0], + formatted: true, + }); + } finally { + served.restore(); + await served.server.close(); + } + }); +}); diff --git a/packages/body/test/limits.test.ts b/packages/body/test/limits.test.ts new file mode 100644 index 00000000..8f7f00fe --- /dev/null +++ b/packages/body/test/limits.test.ts @@ -0,0 +1,311 @@ +import { Binden, type BindenError, type Context, ct_text } from "binden"; +import { + type IBody, + type IReply, + chunked, + echo, + listen, + send, + serve, + sleep, + within, +} from "./helpers.js"; +import { + type IncomingMessage, + type OutgoingHttpHeaders, + request, +} from "node:http"; +import { brotliCompressSync, gzipSync } from "node:zlib"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; +import { once } from "node:events"; +import { randomBytes } from "node:crypto"; + +/** 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({ + host: "127.0.0.1", + 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(10_000), + })) 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" }, + ); + }, + ); + + test( + "a refused body that the error handler echoes: the rest of the request (unpiped by the parser) is read and sent", + { timeout: 10_000 }, + async () => { + const echoed = Promise.withResolvers(); + const server = await listen( + new Binden({ + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + context.status((error as BindenError).status); + await context.send(context.request).then( + () => { + echoed.resolve("resolved"); + }, + (failure: unknown) => { + echoed.resolve(failure); + }, + ); + }, + }).use(new BodyParser({ limit: 4 }), echo), + ); + try { + const req = request({ + host: "127.0.0.1", + port: server.port, + method: "POST", + agent: false, + headers: { "Content-Type": ct_text }, + }); + const response = new Promise((resolve, reject) => { + req.once("response", resolve).once("error", reject); + }); + // Chunked: the parser reads the first chunks, refuses the second + // one, and unpipes the request + req.write("abc"); + await sleep(50); + req.write("defgh"); + await sleep(50); + req.end("ijk"); + const received = await within(response); + let body = ""; + received.setEncoding("latin1").on("data", (chunk: string) => { + body += chunk; + }); + await within(once(received, "end")); + deepStrictEqual( + [received.statusCode, await within(echoed.promise)], + [413, "resolved"], + ); + // What the parser has not read (all of it, once its last chunk has + // come with the one that it refuses) + ok(["ijk", ""].includes(body), body); + } finally { + await server.close(); + } + }, + ); +}); diff --git a/packages/body/test/logging.test.ts b/packages/body/test/logging.test.ts new file mode 100644 index 00000000..35ad004c --- /dev/null +++ b/packages/body/test/logging.test.ts @@ -0,0 +1,159 @@ +import { + Binden, + Context, + type IFunctionMiddleware, + ct_json, + ct_text, +} from "binden"; +import { type IBody, Recorder, echo, listen, send } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; + +/** Sends a request through a parser, with a logger that keeps everything */ +async function post( + 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 { status } = await send(server.url, body, headers); + return [status, logger]; + } finally { + 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 logs", async () => { + 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, + }); + ok(status === 400 || status === 500, String(status)); + const output = JSON.stringify(logger.lines); + ok(logger.lines.length > 0); + 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("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"), + ); + }); + + 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")); + }); + + test("a logger of the context whose methods and `child()` are replaced by ones that throw: the parse settles as without them, and none of them runs (a `log` getter that throws, also of a subclass of `Context`, drops the records only)", async () => { + const calls: string[] = []; + const methods = ["fatal", "error", "warn", "info", "debug", "trace"]; + class Unlogged extends Context { + // eslint-disable-next-line class-methods-use-this -- a getter that fails + public override get log(): Context["log"] { + calls.push("log"); + throw new Error("No logger"); + } + } + const changes: Record = { + replaced: (context) => { + for (const method of [...methods, "child"]) { + Object.assign(context.log, { + [method]: (): never => { + calls.push(method); + throw new Error("A replaced method"); + }, + }); + } + }, + getter: (context) => { + Object.defineProperty(context, "log", { + get: (): never => { + calls.push("log"); + throw new Error("No logger"); + }, + }); + }, + // The next context + subclass: ({ request, response }) => new Unlogged({ request, response }), + }; + const outcomes: unknown[] = []; + for (const [name, change] of Object.entries(changes)) { + const logger = new Recorder(); + const app = new Binden({ logger }).use(change, new BodyParser(), echo); + const server = await listen(app); + try { + const parsed = await send(server.url, '{"a":1}', { + "Content-Type": ct_json, + }); + const refused = await send(server.url, secret, { + "Content-Type": ct_json, + }); + const skipped = await send(server.url, "a", { + "Content-Type": "application/xml", + }); + outcomes.push([ + name, + parsed.status, + parsed.body, + refused.status, + skipped.status, + logger.messages("debug").includes("The JSON body cannot be parsed"), + ]); + } finally { + await server.close(); + } + } + deepStrictEqual(outcomes, [ + // Through the private path of the logger of the context + ["replaced", 200, { type: "json", value: { a: 1 } }, 400, 200, true], + // Read as the parse starts, as the other properties of the context: + // what it throws drops the records of the parse (as those of binden + // through it), never the parse + ["getter", 200, { type: "json", value: { a: 1 } }, 400, 200, false], + ["subclass", 200, { type: "json", value: { a: 1 } }, 400, 200, false], + ]); + // Only the getter, as the framework and the parser get the logger + ok(calls.every((call) => call === "log")); + }); +}); diff --git a/packages/body/test/options.test.ts b/packages/body/test/options.test.ts new file mode 100644 index 00000000..29220cd9 --- /dev/null +++ b/packages/body/test/options.test.ts @@ -0,0 +1,123 @@ +import { BodyParser, type IBodyParserOptions } from "../index.js"; +import { deepStrictEqual, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; + +/** Options of the wrong type, for the tests */ +function options(input: Record): IBodyParserOptions { + return input; +} + +suite("BodyParser (options)", () => { + 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`", () => { + 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", "verify_encoded", "zstd"]) { + 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..9468b81c --- /dev/null +++ b/packages/body/test/process.test.ts @@ -0,0 +1,37 @@ +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("BodyParser (process safety)", () => { + test("late rejections of `verify`, `parse` and `type` do not crash the process", () => { + deepStrictEqual(run("late-rejections.ts"), { + status: 0, + stdout: "503 503 503 500\n", + stderr: "", + }); + }); + + test("what a context that only looks like one throws later is the rejection, never a crash", () => { + deepStrictEqual(run("late-context.ts"), { + status: 0, + stdout: + "200 200 | setHeader of the context | removeEventListener of the context\n", + stderr: "", + }); + }); +}); diff --git a/packages/body/test/setup.ts b/packages/body/test/setup.ts new file mode 100644 index 00000000..60e04105 --- /dev/null +++ b/packages/body/test/setup.ts @@ -0,0 +1,6 @@ +import { Agent, setGlobalDispatcher } from "undici"; + +// 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/body/test/types.test.ts b/packages/body/test/types.test.ts new file mode 100644 index 00000000..da93074e --- /dev/null +++ b/packages/body/test/types.test.ts @@ -0,0 +1,98 @@ +// 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 { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; +import { + BodyParser, + type IBodyKind, + type IBodyParserOptions, + type IParse, +} from "../index.js"; +import type { Context } from "binden"; +import { ok } from "node:assert/strict"; +import { test } from "node:test"; + +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 })); + + // `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({ + 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, + verify: none, + verify_encoded: none, + zstd: none, + disabled: none, + ignore_errors: none, + }; + ok(is>(new BodyParser(options)) instanceof BodyParser); +}); diff --git a/packages/body/test/verify.test.ts b/packages/body/test/verify.test.ts new file mode 100644 index 00000000..f1ea1a89 --- /dev/null +++ b/packages/body/test/verify.test.ts @@ -0,0 +1,189 @@ +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"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { gzipSync } from "node:zlib"; + +/** 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); + }); +}); diff --git a/packages/body/test/zstd-frames.test.ts b/packages/body/test/zstd-frames.test.ts new file mode 100644 index 00000000..e0731c1b --- /dev/null +++ b/packages/body/test/zstd-frames.test.ts @@ -0,0 +1,1097 @@ +import { Binden, BindenError, type Context, ct_text } from "binden"; +import { Duplex, finished } from "node:stream"; +import { + type IZstdBudget, + ZSTD_DECODER_CHARGE, + ZSTD_MAX_DECODER_MEMORY, + ZSTD_MAX_FRAMES, + ZstdDecoding, + ZstdFrames, +} from "../src/zstd.js"; +import { + ZstdDecompress, + constants, + createZstdDecompress, + gzipSync, + zstdCompressSync, + zstdDecompressSync, +} from "node:zlib"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { echo, listen, send, sleep, within } from "./helpers.js"; +import { mock, suite, test } from "node:test"; +import { BodyParser } from "../index.js"; +import fc from "fast-check"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { request } from "node:http"; +import { spawnSync } from "node:child_process"; + +/** The magic number of a `zstd` frame */ +const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); + +const KiB = 1024; +const MiB = 1024 * KiB; + +/** The `Window_Descriptor` of a window of 8 MiB (`Exponent` 13) */ +const WINDOW_8_MIB = 13 << 3; + +/** + * A frame of one RLE block of `count` bytes of `a` (128 KiB at most), of a + * `Window_Descriptor` (1 KiB by default), that declares `declared` bytes of + * content in 4 bytes, or nothing when `null` + */ +function rle( + count: number, + window = 0, + declared: number | null = null, +): Buffer { + const size = Buffer.alloc(declared === null ? 0 : 4); + if (declared !== null) { + size.writeUInt32LE(declared); + } + const block = Buffer.alloc(4); + block.writeUIntLE((count << 3) | (1 << 1) | 1, 0, 3); + block.writeUInt8(0x61, 3); + return Buffer.concat([ + MAGIC, + Buffer.from([declared === null ? 0 : 0x80, window]), + size, + block, + ]); +} + +/** A skippable frame (its magic number ends with `nibble`) */ +function skippable(nibble: number, data: Buffer): Buffer { + const header = Buffer.alloc(8); + header.writeUInt32LE(0x184d2a50 + nibble, 0); + header.writeUInt32LE(data.length, 4); + return Buffer.concat([header, data]); +} + +/** The `index`th of `count` slices of `data` of about the same size (none empty) */ +function slice(data: Buffer, index: number, count: number): Buffer { + const at = (part: number): number => Math.floor((part * data.length) / count); + return data.subarray(at(index), at(index + 1)); +} + +/** `count` frames of `make(index)` */ +function frames(count: number, make: (index: number) => Buffer): Buffer { + return Buffer.concat( + Array.from({ length: count }, (_, index) => make(index)), + ); +} + +/** + * A frame of a window of 1 KiB of raw blocks of `texts` and an empty last + * block (whose end the decoder of Node.js does not check against the + * declared size when it gets the frame in pieces): it declares `declared` + * bytes of content in 4 bytes (8 when `long`), or nothing when `null` + */ +function sized( + texts: readonly string[], + declared: bigint | null, + long = false, +): Buffer { + const size = Buffer.alloc(declared === null ? 0 : long ? 8 : 4); + if (long) { + size.writeBigUInt64LE(declared ?? 0n); + } else if (declared !== null) { + size.writeUInt32LE(Number(declared)); + } + const blocks = texts.map((text) => { + const header = Buffer.alloc(3); + header.writeUIntLE(Buffer.byteLength(text) << 3, 0, 3); + return Buffer.concat([header, Buffer.from(text)]); + }); + return Buffer.concat([ + MAGIC, + Buffer.from([declared === null ? 0 : long ? 0xc0 : 0x80, 0]), + size, + ...blocks, + // The empty last block + Buffer.from([1, 0, 0]), + ]); +} + +/** Whether an error is the refusal of a frame whose content is not of its declared size */ +function missized(error: unknown): boolean { + return ( + error instanceof Error && + error.message === "The content of a zstd frame is not of its declared size" + ); +} + +/** A frame of one byte of content (`a`) */ +const one = (): Buffer => zstdCompressSync("a"); + +/** A frame of an empty content */ +const empty = (): Buffer => zstdCompressSync(""); + +/** Skippable frames of each magic number */ +const skipped = (index: number): Buffer => + skippable(index % 16, Buffer.alloc(index % 3)); + +/** + * An empty frame, a frame of data, an empty frame and a skippable one, in + * turn: 256 frames of data (of a decoder each) in 1024 frames + */ +function mixed(index: number): Buffer { + if (index % 4 === 1) { + return one(); + } + return index % 4 === 3 ? skipped(index) : empty(); +} + +/** What the decoders of Node.js have got so far */ +interface IFed { + /** The decoders (one per data frame) */ + decoders: number; + /** The writes to them (with the writes of `end()`) */ + writes: number; +} + +/** Counts the decoders of Node.js and their writes while `run` runs */ +async function counting(run: (fed: () => IFed) => Promise): Promise { + const write = mock.method(Duplex.prototype, "write"); + const end = mock.method(Duplex.prototype, "end"); + try { + return await run(() => { + const calls = [...write.mock.calls, ...end.mock.calls].filter( + (call) => call.this instanceof ZstdDecompress, + ); + return { + decoders: new Set(calls.map((call) => call.this)).size, + writes: calls.length, + }; + }); + } finally { + write.mock.restore(); + end.mock.restore(); + } +} + +/** What a request gets: its status and `Connection`, the content (or the `code` of the refusal) */ +interface IOutcome { + status: number; + connection: unknown; + content: unknown; +} + +/** Sends a body of `coding` through a parser of `zstd` (then `echo`) */ +async function post(body: Buffer, coding: string): Promise { + let code: unknown = null; + const parser = new BodyParser({ zstd: true }); + const server = await listen( + new Binden().use(async (context: Context) => { + await parser.run(context).catch((error: unknown) => { + code = error instanceof BindenError ? error.code : error; + throw error; + }); + }, echo), + ); + try { + const reply = await send(server.url, body, { + "Content-Type": ct_text, + "Content-Encoding": coding, + }); + const { value } = (reply.body ?? {}) as { value?: unknown }; + return { + status: reply.status, + connection: reply.connection, + content: reply.status === 200 ? value : code, + }; + } finally { + await server.close(); + } +} + +/** A body within the limit of frames */ +const accepted = (content: string): IOutcome => ({ + status: 200, + connection: "keep-alive", + content, +}); + +/** A body of more frames (or decoders) than the limits */ +const refused: IOutcome = { + status: 413, + connection: "close", + content: "zstd_frames", +}; + +/** What a decoding gets out of chunks (until it fails), and what its decoders of Node.js get */ +interface IDecoded extends IFed { + content: string; + error: unknown; +} + +/** Writes the chunks to a decoding, each once it has taken the previous one */ +async function decode( + chunks: readonly Buffer[], + budget: IZstdBudget = { frames: 0, memory: 0 }, +): Promise { + const decoding = new ZstdDecoding(budget); + const output: Buffer[] = []; + decoding.on("data", (chunk: Buffer) => output.push(chunk)); + const ended = new Promise((resolve) => { + finished(decoding, (failure) => { + resolve(failure ?? null); + }); + }); + return counting(async (fed) => { + for (const chunk of chunks) { + decoding.write(chunk); + } + decoding.end(); + const error = await within(ended); + return { + content: Buffer.concat(output).toString(), + error, + ...fed(), + }; + }); +} + +/** Whether an error is the refusal of a frame (or a decoder) beyond the limits */ +function beyond(error: unknown): boolean { + return ( + error instanceof BindenError && + error.status === 413 && + error.code === "zstd_frames" + ); +} + +suite("BodyParser (zstd frames)", () => { + test("the limits are 1024 frames and 64 MiB of decoders (at least 256 KiB each), whatever the version of Node.js", () => { + deepStrictEqual( + [ZSTD_MAX_FRAMES, ZSTD_MAX_DECODER_MEMORY, ZSTD_DECODER_CHARGE], + [1024, 64 * MiB, 256 * KiB], + ); + }); + + test("a request has at most 1024 frames: data, empty and skippable ones", async () => { + for (const [name, make, content] of [ + ["empty", empty, ""], + ["skippable", skipped, ""], + ["mixed", mixed, "a".repeat(256)], + ] as const) { + deepStrictEqual( + await post(frames(1024, make), "zstd"), + accepted(content), + `${name}: 1024`, + ); + deepStrictEqual( + await post(frames(1025, make), "zstd"), + refused, + `${name}: 1025`, + ); + } + }); + + test("a request has at most 64 MiB of decoders: 256 of the least charge, 8 of the largest window", async () => { + // Frames of 1 byte (a single segment: a window of 1 byte) + deepStrictEqual( + await post(frames(256, one), "zstd"), + accepted("a".repeat(256)), + ); + deepStrictEqual(await post(frames(257, one), "zstd"), refused); + // One byte of a window of 8 MiB, of no declared size + const windowed = (): Buffer => rle(1, WINDOW_8_MIB); + deepStrictEqual( + await post(frames(8, windowed), "zstd"), + accepted("a".repeat(8)), + ); + deepStrictEqual(await post(frames(9, windowed), "zstd"), refused); + deepStrictEqual(await post(frames(1024, windowed), "zstd"), refused); + // That declares its size of 1 byte, or of 1 MiB (which it does not have) + const declaring = (): Buffer => rle(1, WINDOW_8_MIB, 1); + deepStrictEqual( + await post(frames(256, declaring), "zstd"), + accepted("a".repeat(256)), + ); + deepStrictEqual(await post(frames(257, declaring), "zstd"), refused); + deepStrictEqual( + await post( + frames(65, () => rle(1, WINDOW_8_MIB, MiB)), + "zstd", + ), + refused, + ); + }); + + test("a decoder is charged its window, or its declared size when smaller, and at least 256 KiB", () => { + /** What the decoder of a frame is charged (its header and its first block are enough) */ + const charge = (frame: Buffer): number => { + const budget = { frames: 0, memory: 0 }; + new ZstdFrames(budget).scan(frame); + return budget.memory; + }; + // A window of 6 MiB: 4 MiB (`Exponent` 12) and 4 eighths of it + const window6 = (12 << 3) | 4; + for (const [name, frame, charged] of [ + ["a single segment of 1 byte", one(), 256 * KiB], + ["a window of 1 KiB", rle(1), 256 * KiB], + ["a window of 8 MiB", rle(1, WINDOW_8_MIB), 8 * MiB], + ["a window of 6 MiB", rle(1, window6), 6 * MiB], + [ + "a window of 8 MiB, 3 MiB declared", + rle(1, WINDOW_8_MIB, 3 * MiB), + 3 * MiB, + ], + ["a window of 6 MiB, 7 MiB declared", rle(1, window6, 7 * MiB), 6 * MiB], + [ + "a window of 8 MiB, 1 KiB declared", + rle(1, WINDOW_8_MIB, KiB), + 256 * KiB, + ], + [ + "a single segment of 5 MiB", + Buffer.from([...MAGIC, 0xa0, 0, 0, 0x50, 0, 9, 0, 0, 0x61]), + 5 * MiB, + ], + ["an empty frame", empty(), 0], + ["an empty frame of a window of 8 MiB", rle(0, WINDOW_8_MIB), 0], + ["a skippable frame", skippable(0, Buffer.alloc(4)), 0], + ] as const) { + deepStrictEqual(charge(frame), charged, name); + } + }); + + test("the 257th decoder of a request never gets a byte", async () => { + const all = frames(257, one); + const size = all.length / 257; + // A frame per chunk + const each = Array.from({ length: 257 }, (_, index) => + all.subarray(index * size, (index + 1) * size), + ); + const outcome = await decode(each); + ok(beyond(outcome.error)); + deepStrictEqual( + [outcome.content, outcome.decoders, outcome.writes], + ["a".repeat(256), 256, 512], + ); + // All of them in one chunk + const over = await decode([all]); + ok(beyond(over.error)); + deepStrictEqual([over.content, over.decoders, over.writes], ["", 0, 0]); + }); + + test("the decoders of every `zstd` coding of a request count together", async () => { + // The frames of the inner coding are in the content of the outer one, of + // a decoder each + const inner = (count: number): Buffer => frames(count, one); + const outer = (content: Buffer, count: number): Buffer => + frames(count, (index) => zstdCompressSync(slice(content, index, count))); + deepStrictEqual( + await post(outer(inner(128), 128), "zstd, zstd"), + accepted("a".repeat(128)), + ); + deepStrictEqual(await post(outer(inner(129), 128), "zstd, zstd"), refused); + deepStrictEqual(await post(outer(inner(128), 129), "zstd, zstd"), refused); + }); + + test("the frames of every `zstd` coding of a request count together", async () => { + // The frames of the inner coding are in the content of the outer one: + // one frame of data, then empty ones + const inner = (count: number): Buffer => + Buffer.concat([one(), frames(count - 1, empty)]); + const outer = (content: Buffer, count: number): Buffer => + frames(count, (index) => zstdCompressSync(slice(content, index, count))); + deepStrictEqual( + await post(outer(inner(896), 128), "zstd, zstd"), + accepted("a"), + ); + deepStrictEqual(await post(outer(inner(897), 128), "zstd, zstd"), refused); + deepStrictEqual(await post(outer(inner(896), 129), "zstd, zstd"), refused); + + // A coding of another kind has no frames + const gzipped = gzipSync("gzip"); + const withEmpty = (count: number): Buffer => + Buffer.concat([zstdCompressSync(gzipped), frames(count - 1, empty)]); + deepStrictEqual( + await post(withEmpty(1024), "gzip, zstd"), + accepted("gzip"), + ); + deepStrictEqual(await post(withEmpty(1025), "gzip, zstd"), refused); + deepStrictEqual( + await post(gzipSync(inner(1024)), "zstd, gzip"), + accepted("a"), + ); + deepStrictEqual(await post(gzipSync(inner(1025)), "zstd, gzip"), refused); + }); + + test( + "a body of tiny frames costs at most 1024 steps of the decoding, and 256 decoders", + { timeout: 30_000 }, + async () => { + const deadline = 5000; + // 1 MiB of 256 frames is decoded in time + const data = randomBytes(1024 * 1024); + const large = frames(256, (index) => + zstdCompressSync(data.subarray(index * 4096, (index + 1) * 4096)), + ); + const parser = new BodyParser({ zstd: true, raw: true }); + const server = await listen( + new Binden().use(parser, (context: Context) => + context.json({ same: data.equals(context.request.body as Buffer) }), + ), + ); + try { + const started = performance.now(); + const reply = await send(server.url, large, { + "Content-Type": "application/octet-stream", + "Content-Encoding": "zstd", + }); + deepStrictEqual(reply.body, { same: true }); + ok(performance.now() - started < deadline); + } finally { + await server.close(); + } + + // 1024 frames that each declare a window of 8 MiB (and one raw byte of + // content, without its size): refused at the 9th decoder + const windowed = Buffer.from([ + 0x28, + 0xb5, + 0x2f, + 0xfd, + 0, + WINDOW_8_MIB, + 0x09, + 0, + 0, + 0x61, + ]); + const before = performance.now(); + deepStrictEqual( + await post( + frames(1024, () => windowed), + "zstd", + ), + refused, + ); + ok(performance.now() - before < deadline); + + // 1 MiB of empty frames (9 bytes each) is refused at once + const tiny = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x20, 0, 1, 0, 0]); + const body = frames(Math.floor((1024 * 1024) / tiny.length), () => tiny); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const refusing = await listen( + new Binden().use(async (context: Context) => { + await parser.run(context).catch((error: unknown) => { + settle(error); + throw error; + }); + }, echo), + ); + const client = request({ + host: "127.0.0.1", + port: refusing.port, + method: "POST", + agent: false, + headers: { + "Content-Type": ct_text, + "Content-Encoding": "zstd", + "Content-Length": body.length, + }, + }); + client.on("error", () => { + // The server closes the connection after its reply, while the rest of + // the body is still sent (its status is checked with smaller bodies) + }); + try { + const started = performance.now(); + client.write(body); + ok(beyond(await within(settled, deadline))); + ok(performance.now() - started < deadline); + } finally { + client.destroy(); + await refusing.close(); + } + }, + ); + + test("the most decoders that a request may cost, each of the largest window, take little time and memory", () => { + for (const [kind, content] of [ + // 256 decoders of 128 KiB each + ["decoders", 256 * 128 * KiB], + // 8 decoders of a window of 8 MiB each + ["windows", 8 * 128 * KiB], + ] as const) { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "zstd-decoders.ts"), + kind, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual([status, stderr], [0, ""], kind); + const { size, ms, growth } = JSON.parse(stdout) as Record; + deepStrictEqual(size, content, kind); + // One decoder at a time: about 40 ms and 20 MiB (256 decoders), and + // 20 ms and 50 MiB (8 windows of 8 MiB) on a laptop + ok(ms < 5000, `${kind}: ${ms} ms`); + ok(growth < 160, `${kind}: ${growth} MiB`); + } + }); + + test("1024 frames are decoded, a chunk with the 1025th never reaches a decoder", async () => { + // The bytes of one frame per chunk: 256 frames of data among them, and + // the 1025th is empty + const each = Array.from({ length: 1025 }, (_, index) => mixed(index)); + // A decoder per frame of data, which gets its head, then the rest + deepStrictEqual(await decode(each.slice(0, 1024)), { + content: "a".repeat(256), + error: null, + decoders: 256, + writes: 512, + }); + const outcome = await decode(each); + ok(beyond(outcome.error)); + deepStrictEqual([outcome.decoders, outcome.writes], [256, 512]); + + // All of them in one chunk + deepStrictEqual(await decode([Buffer.concat(each.slice(0, 1024))]), { + content: "a".repeat(256), + error: null, + decoders: 256, + writes: 512, + }); + const over = await decode([Buffer.concat(each)]); + ok(beyond(over.error)); + deepStrictEqual([over.decoders, over.writes], [0, 0]); + }); + + test("the frames and the decoders count in the budget of the request", async () => { + const budget = { frames: 1000, memory: 0 }; + // An empty frame has no decoder + deepStrictEqual(await decode([empty()], budget), { + content: "", + error: null, + decoders: 0, + writes: 0, + }); + deepStrictEqual(budget, { frames: 1001, memory: 0 }); + deepStrictEqual(await decode([one()], budget), { + content: "a", + error: null, + decoders: 1, + writes: 2, + }); + deepStrictEqual(budget, { frames: 1002, memory: 256 * KiB }); + const parts = Array.from({ length: 22 }, () => empty()); + deepStrictEqual((await decode(parts, budget)).error, null); + ok(beyond((await decode([empty()], budget)).error)); + + // The decoder that the budget still has + const memory = { frames: 0, memory: 64 * MiB - 256 * KiB }; + deepStrictEqual((await decode([one()], memory)).content, "a"); + deepStrictEqual(memory, { frames: 1, memory: 64 * MiB }); + const over = await decode([one()], memory); + ok(beyond(over.error)); + deepStrictEqual([over.decoders, over.writes], [0, 0]); + }); + + test("a frame split over chunks, and a chunk of several frames", async () => { + const first = zstdCompressSync("first"); + const second = skippable(5, Buffer.from("skipped")); + const third = zstdCompressSync("third"); + // Every chunk ends within a frame (its magic number too), or at its end + const split = [ + first.subarray(0, 3), + first.subarray(3, 4), + first.subarray(4), + second.subarray(0, 2), + second.subarray(2), + third.subarray(0, 4), + third.subarray(4, 7), + third.subarray(7), + ]; + deepStrictEqual(await decode(split), { + content: "firstthird", + error: null, + decoders: 2, + // Not the skippable frame: the head of a frame (once its declared size + // is read), then the rest of each chunk + writes: 5, + }); + const several = Buffer.concat([first, second, third]); + for (let offset = 1; offset < several.length; offset += 1) { + const halves = [several.subarray(0, offset), several.subarray(offset)]; + const { content, error, decoders } = await decode(halves); + deepStrictEqual( + { content, error, decoders }, + { content: "firstthird", error: null, decoders: 2 }, + `split at ${offset}`, + ); + } + }); + + test("a frame is decoded once the decoder of the previous one has ended, and its decoder gets a piece once it has taken the previous one", async () => { + // Incompressible: more than the buffers of the decoders + const data = randomBytes(2 * 1024 * 1024); + const half = data.length / 2; + const parts = [ + zstdCompressSync(data.subarray(0, half)), + zstdCompressSync(data.subarray(half)), + ]; + const whole = Buffer.concat(parts); + const quarter = Math.floor(parts[0].length / 2); + // Before anything reads the content (the magic number and the first + // frame, or its first half), and once it has been read + for (const [name, chunks, before, after] of [ + ["one chunk", [whole], 2, 4], + ["a chunk per frame", parts, 2, 4], + [ + "halves of a frame", + [whole.subarray(0, quarter), whole.subarray(quarter)], + 2, + 5, + ], + ] as const) { + const decoding = new ZstdDecoding({ frames: 0, memory: 0 }); + await counting(async (fed) => { + for (const chunk of chunks) { + decoding.write(chunk); + } + decoding.end(); + await sleep(100); + deepStrictEqual(fed(), { decoders: 1, writes: before }, name); + const content = Buffer.concat(await within(decoding.toArray())); + ok(content.equals(data), name); + deepStrictEqual(fed(), { decoders: 2, writes: after }, name); + }); + } + }); + + test("the content of a frame is checked against its declared size once its decoder has ended, before the next frame", async () => { + // [3, 2] declared, [2, 3] decoded + const compensating = Buffer.concat([sized(["ab"], 3n), sized(["cde"], 2n)]); + const outcome = await decode([compensating]); + ok(missized(outcome.error)); + // The decoder of the next frame never starts + deepStrictEqual( + [outcome.content, outcome.decoders, outcome.writes], + ["ab", 1, 2], + ); + deepStrictEqual( + await decode([Buffer.concat([sized(["ab"], 2n), sized(["cde"], 3n)])]), + { content: "abcde", error: null, decoders: 2, writes: 4 }, + ); + }); + + test("a frame that produces more than its declared size fails at once, before more than its size goes on", async () => { + // Larger than its window (and its blocks): the decoder of Node.js + // decodes it through a ring buffer, and does not check its size + const texts = Array.from({ length: 6 }, () => "a".repeat(1000)); + const over = sized(texts, 5000n); + const whole = await decode([over, one()]); + ok(missized(whole.error)); + // In one piece, or one byte at a time + deepStrictEqual([whole.content, whole.decoders], ["", 1]); + const bytes = await decode([...over].map((byte) => Buffer.from([byte]))); + ok(missized(bytes.error)); + ok(bytes.content.length <= 5000); + // Unless it does not declare its size + deepStrictEqual(await decode([sized(texts, null), one()]), { + content: `${texts.join("")}a`, + error: null, + decoders: 2, + writes: 4, + }); + }); + + test("the declared sizes are checked wherever the chunks end: within a header, within the last block", async () => { + const unsized = sized(["xyz"], null); + for (const [name, body, content] of [ + ["compensating sizes", [sized(["ab"], 3n), sized(["cde"], 2n)], null], + ["before a frame without a size", [sized(["ab"], 3n), unsized], null], + ["after a frame without a size", [unsized, sized(["ab"], 3n)], null], + ["8 bytes: 2^32 + 2", [sized(["ab"], 2n ** 32n + 2n, true)], null], + ["8 bytes: 2^64 - 1", [sized(["ab"], 2n ** 64n - 1n, true)], null], + [ + "of their sizes", + [sized(["ab"], 2n), unsized, sized(["c", "d"], 2n)], + "abxyzcd", + ], + ["8 bytes", [sized(["ab"], 2n, true), unsized], "abxyz"], + ] as const) { + const all = Buffer.concat(body); + const splits = [ + // One byte at a time + Array.from({ length: all.length }, (_, index) => + all.subarray(index, index + 1), + ), + ...Array.from({ length: all.length - 1 }, (_, index) => [ + all.subarray(0, index + 1), + all.subarray(index + 1), + ]), + ]; + for (const chunks of splits) { + const outcome = await decode(chunks); + const where = `${name}: ${chunks.map((chunk) => chunk.length).join(", ")}`; + if (content === null) { + ok(missized(outcome.error), where); + } else { + deepStrictEqual( + [outcome.content, outcome.error], + [content, null], + where, + ); + } + } + } + }); +}); + +/** A frame of the given bytes (in hexadecimal) after its magic number */ +function hex(bytes: string): Buffer { + return Buffer.concat([MAGIC, Buffer.from(bytes.replaceAll(" ", ""), "hex")]); +} + +/** Decodes a frame whole as Node.js does, with the largest window of the parser */ +function decodedByNode(frame: Buffer): Buffer { + return zstdDecompressSync(frame, { + params: { [constants.ZSTD_d_windowLogMax]: 23 }, + }); +} + +/** + * Empty frames of every kind of header and of empty block, which need no + * decoder (the low 4 bytes of the XXH64 of no data are `99e9d851`) + */ +const emptyFrames: readonly [string, Buffer][] = [ + ["a window of 8 MiB", hex("00 68 010000")], + ["a single segment of size 0", hex("20 00 010000")], + ["a single segment of size 0, a checksum", hex("24 00 010000 99e9d851")], + ["a size of 0 in 4 bytes", hex("80 68 00000000 010000")], + [ + "a size of 0 in 8 bytes, a checksum", + hex("c4 68 0000000000000000 010000 99e9d851"), + ], + ["a dictionary ID of 0 in 1 byte", hex("01 68 00 010000")], + ["a dictionary ID of 0 in 2 bytes", hex("02 68 0000 010000")], + [ + "a dictionary ID of 0 in 4 bytes, a single segment", + hex("23 00000000 00 010000"), + ], + ["the unused bit", hex("10 68 010000")], + ["an RLE block of no byte", hex("00 68 030000 61")], + ["a compressed block of no byte", hex("00 68 050000")], + [ + "empty blocks of each type, a checksum", + hex("04 00 000000 020000 61 040000 010000 99e9d851"), + ], +]; + +/** + * Frames without content that a decoder refuses, and the content size that + * they declare (those that declare one get a decoder) + */ +const refusedFrames: readonly [string, Buffer, bigint | null][] = [ + [ + "a checksum that is not that of an empty content", + hex("24 00 010000 98e9d851"), + 0n, + ], + ["a dictionary ID of 5", hex("01 68 05 010000"), null], + ["a dictionary ID of 2^24 in 4 bytes", hex("03 68 00000001 010000"), null], + ["the reserved bit", hex("08 68 010000"), null], + ["a window of 9 MiB", hex("00 69 010000"), null], + ["a window of 3.75 TiB", hex("00 ff 010000"), null], + [ + "a single segment of 8 MiB and 1 byte", + hex("a0 01008000 010000"), + 8n * 1024n * 1024n + 1n, + ], + ["a reserved block of no byte", hex("00 68 070000"), null], + // Whose window is its content size: none, for the 1 byte of an RLE block + ["an RLE block in a single segment of size 0", hex("20 00 030000 61"), 0n], + ["a declared size of 256", hex("40 68 0000 010000"), 256n], + ["a single segment of size 1", hex("20 01 010000"), 1n], +]; + +/** One chunk of each byte */ +const bytewise = (data: Buffer): Buffer[] => + [...data].map((byte) => Buffer.from([byte])); + +/** + * What the decoder of Node.js produces out of a frame that it gets in + * pieces, as a decoding gives it, with the size check of a decoding: its + * size, or `null` when it refuses it + */ +async function decodedInPieces( + frame: Buffer, + declared: bigint | null, +): Promise { + const decoder = createZstdDecompress({ + params: { [constants.ZSTD_d_windowLogMax]: 23 }, + }); + for (const chunk of bytewise(frame)) { + decoder.write(chunk); + } + decoder.end(); + try { + const size = Buffer.concat(await within(decoder.toArray())).length; + return declared === null || BigInt(size) === declared ? size : null; + } catch { + return null; + } +} + +/** A frame of a property: its bytes, and the content size that it declares */ +interface ICrafted { + bytes: Buffer; + declared: bigint | null; +} + +/** + * Frames of any header, of up to 3 blocks without content (or of a byte), + * among them of the reserved type, and of a checksum of an empty content or + * of any other + */ +const craftedFrame: fc.Arbitrary = fc + .record({ + descriptor: fc.integer({ min: 0, max: 255 }), + window: fc.constantFrom(0x00, 0x58, 0x68, 0x69, 0xff), + dictionary: fc.oneof( + fc.constant(0), + fc.integer({ min: 0, max: 2 ** 32 - 1 }), + ), + size: fc.oneof(fc.constant(0), fc.integer({ min: 0, max: 3 })), + blocks: fc.array( + fc.constantFrom( + "000000", + "020000 61", + "040000", + "060000", + "080000 62", + "0a0000 63", + ), + { minLength: 1, maxLength: 3 }, + ), + checksum: fc.oneof( + fc.constant(0x51d8e999), + fc.integer({ min: 0, max: 2 ** 32 - 1 }), + ), + }) + .map(({ descriptor, window, dictionary, size, blocks, checksum }) => { + const single = (descriptor & 0x20) !== 0; + const flag = descriptor >>> 6; + const sizeBytes = flag === 0 ? Number(single) : 1 << flag; + const dictionaryBytes = [0, 1, 2, 4][descriptor & 0x03] ?? 0; + const field = (value: number, length: number): Buffer => { + const bytes = Buffer.alloc(8); + bytes.writeBigUInt64LE(BigInt(value)); + return bytes.subarray(0, length); + }; + const last = blocks.map((block, index) => { + const bytes = Buffer.from(block.replaceAll(" ", ""), "hex"); + bytes[0] |= Number(index === blocks.length - 1); + return bytes; + }); + const sum = Buffer.alloc(4); + sum.writeUInt32LE(checksum); + return { + bytes: Buffer.concat([ + MAGIC, + Buffer.from(single ? [descriptor] : [descriptor, window]), + field(dictionary, dictionaryBytes), + field(size, sizeBytes), + ...last, + (descriptor & 0x04) === 0 ? Buffer.alloc(0) : sum, + ]), + declared: + sizeBytes === 0 ? null : BigInt(size + (sizeBytes === 2 ? 256 : 0)), + }; + }); + +suite("BodyParser (empty zstd frames)", () => { + test("1024 empty frames of every kind get no decoder, and are an empty body", async () => { + for (const [name, frame] of emptyFrames) { + // As Node.js decodes them + deepStrictEqual(decodedByNode(frame).length, 0, name); + const all = frames(1024, () => frame); + const rest = frames(1023, () => frame); + for (const chunks of [[all], bytewise(frame), [frame, rest]]) { + deepStrictEqual( + await decode(chunks), + { content: "", error: null, decoders: 0, writes: 0 }, + `${name}: ${chunks.length} chunks`, + ); + } + deepStrictEqual(await post(all, "zstd"), accepted(""), name); + deepStrictEqual( + await post( + frames(1025, () => frame), + "zstd", + ), + refused, + name, + ); + } + }); + + test("an empty frame is refused as its decoder would refuse it, without one", async () => { + for (const [name, frame, declared] of refusedFrames) { + // As a decoding gives it to a decoder (and with the check of its size) + deepStrictEqual(await decodedInPieces(frame, declared), null, name); + // A frame that declares content (but too large a window) has a decoder + const decoders = Number( + declared !== null && declared > 0n && declared <= 8n * 1024n * 1024n, + ); + for (const chunks of [[frame], bytewise(frame)]) { + const outcome = await decode(chunks); + ok( + outcome.error instanceof Error && + !(outcome.error instanceof BindenError), + name, + ); + deepStrictEqual( + [outcome.content, outcome.decoders], + ["", decoders], + name, + ); + } + deepStrictEqual( + await post(Buffer.concat([one(), frame]), "zstd"), + { status: 400, connection: "close", content: null }, + name, + ); + } + // Truncated: within its empty blocks, within its checksum + for (const truncated of [ + hex("00 68 000000"), + hex("00 68 0100"), + hex("24 00 010000 99e9"), + ]) { + const outcome = await decode([truncated]); + ok(outcome.error instanceof Error, truncated.toString("hex")); + deepStrictEqual(outcome.decoders, 0); + } + }); + + test("the empty blocks before the first block of content are left out of its decoder, which gets a head of 21 bytes at most", async () => { + // Raw, RLE and compressed empty blocks, then "hello" (and its checksum), + // of the largest header: a dictionary ID of 0 in 4 bytes, and a size of + // 0 in 8 bytes (which a frame of content contradicts) + const empties = "000000 020000 61 040000 ".repeat(1000); + const hello = hex(`07 00 00000000 ${empties} 290000 68656c6c6f a36d9f88`); + deepStrictEqual(decodedByNode(hello).toString(), "hello"); + const write = mock.method(ZstdDecompress.prototype, "write"); + try { + for (const chunks of [[hello], bytewise(hello)]) { + write.mock.resetCalls(); + const outcome = await decode(chunks); + deepStrictEqual( + [outcome.content, outcome.error, outcome.decoders], + ["hello", null, 1], + ); + deepStrictEqual( + write.mock.calls[0]?.arguments[0], + hex("07 00 00000000 290000"), + ); + } + // Which its decoder refuses + const largest = hex( + `c3 00 00000000 0000000000000000 ${empties} 290000 68656c6c6f`, + ); + write.mock.resetCalls(); + ok((await decode([largest])).error instanceof Error); + const head = write.mock.calls[0]?.arguments[0] as Buffer | undefined; + deepStrictEqual(head?.length, 21); + } finally { + write.mock.restore(); + } + }); + + test("a body of frames without content is decoded as the decoder of Node.js decodes each frame", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(craftedFrame, { minLength: 1, maxLength: 3 }), + fc.array(fc.integer({ min: 1, max: 9 }), { minLength: 1 }), + async (crafted, sizes) => { + // A frame refused by a decoder of its own (also for a content not + // of its declared size) refuses the body + const each = await Promise.all( + crafted.map(({ bytes, declared }) => + decodedInPieces(bytes, declared), + ), + ); + const expected = each.includes(null) + ? null + : "".padEnd( + each.reduce((a, b) => a + (b ?? 0), 0), + "?", + ); + const body = Buffer.concat(crafted.map(({ bytes }) => bytes)); + const chunks: Buffer[] = []; + for (let at = 0, index = 0; at < body.length; index += 1) { + const size = sizes[index % sizes.length] ?? 1; + chunks.push(body.subarray(at, at + size)); + at += size; + } + for (const split of [[body], bytewise(body), chunks]) { + const { content, error } = await decode(split); + const actual = + error === null ? content.replaceAll(/[^?]/gu, "?") : null; + if (actual !== expected) { + return false; + } + } + return true; + }, + ), + // Fixed, so that every run checks the same bodies (`FC_SEED` and + // `FC_RUNS` check others) + { + seed: Number.parseInt(process.env.FC_SEED ?? "20261016", 10), + numRuns: Number.parseInt(process.env.FC_RUNS ?? "200", 10), + }, + ); + }); + + test("20 concurrent requests of the most costly bodies create a decoder only for a frame that is not empty, and take little time and memory", () => { + for (const [kind, decoders, cpu] of [ + // 1024 empty frames of a window of 8 MiB each (no decoder) + ["empty", 0, 200], + // 256 decoders of a window of 8 MiB each, that declare 1 byte + ["windows", 20 * 256, 750], + // 256 decoders of an RLE block of 1 byte + ["rle", 20 * 256, 750], + ] as const) { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "zstd-concurrent.ts"), + kind, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual([status, stderr], [0, ""], kind); + const outcome = JSON.parse(stdout) as Record; + deepStrictEqual(outcome.statuses, [200], kind); + // The structure of the work, whatever the load of the machine: a + // decoder for each frame that is not empty, none for an empty one + deepStrictEqual(outcome.zlib, decoders, kind); + // About 3 ms (empty) and 18 ms (decoders) of CPU per request, 3 and 12 + // collections, and 2 and 100 to 220 MiB on a laptop (with a decoder + // per frame, empty frames took 260 ms and 800 collections). The CPU + // time is only a loose bound, far above these: the load of the machine + // and the coverage multiply it + ok(Number(outcome.cpu) < cpu, `${kind}: ${String(outcome.cpu)} ms`); + ok(Number(outcome.gcs) < 200, `${kind}: ${String(outcome.gcs)} GCs`); + const memory = kind === "empty" ? 64 : 512; + ok( + Number(outcome.growth) < memory, + `${kind}: ${String(outcome.growth)} MiB`, + ); + } + }); +}); diff --git a/packages/body/test/zstd.test.ts b/packages/body/test/zstd.test.ts new file mode 100644 index 00000000..4c1aaf2e --- /dev/null +++ b/packages/body/test/zstd.test.ts @@ -0,0 +1,570 @@ +import { + Binden, + BindenAbortError, + BindenError, + type Context, + ct_text, +} from "binden"; +import { + type IBody, + type IReply, + chunked, + echo, + listen, + send, + serve, + sleep, + within, +} from "./helpers.js"; +import { type IncomingMessage, request } from "node:http"; +import { + brotliCompressSync, + constants, + createZstdCompress, + deflateSync, + gzipSync, + zstdCompressSync, +} from "node:zlib"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { BodyParser } from "../index.js"; +import { once } from "node:events"; +import { randomBytes } from "node:crypto"; + +/** The magic number of a `zstd` frame */ +const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); + +const hello = "Hello World"; + +/** A block of a crafted frame: raw bytes, or a byte repeated (RLE) */ +type IBlock = { raw: string } | { rle: string; count: number }; + +/** The header of a crafted frame */ +interface IHeader { + /** A single segment (no `Window_Descriptor`), which has its size */ + single?: boolean; + /** `Dictionary_ID_flag` (the identifier is `0`: no dictionary) */ + dictionary?: number; + /** `Frame_Content_Size_flag` */ + content?: number; + /** `Frame_Content_Size` (by default, the size of the content) */ + declared?: bigint; +} + +/** The header of a block: its size, its type and whether it is the last one */ +function blockHeader(size: number, type: number, last: boolean): Buffer { + const header = Buffer.alloc(3); + header.writeUIntLE((size << 3) | (type << 1) | Number(last), 0, 3); + return header; +} + +/** The bytes of a block once decoded */ +function contentOf(block: IBlock): string { + if ("raw" in block) { + return block.raw; + } + return block.rle.repeat(block.count); +} + +/** + * A `zstd` frame of raw and RLE blocks (RFC 8878), with every kind of header: + * its content is the bytes of its blocks + */ +function frame( + blocks: readonly IBlock[], + { single = true, dictionary = 0, content = 0, declared }: IHeader = {}, +): Buffer { + const text = blocks.map(contentOf).join(""); + const size = Number(declared ?? Buffer.byteLength(text)); + const descriptor = (content << 6) | (single ? 0x20 : 0) | dictionary; + const parts: Buffer[] = [MAGIC, Buffer.from([descriptor])]; + if (!single) { + // A window of 1 KiB + parts.push(Buffer.from([0])); + } + parts.push(Buffer.alloc(dictionary === 3 ? 4 : dictionary)); + const field = Buffer.alloc(content === 0 ? Number(single) : 1 << content); + if (field.length === 1) { + field.writeUInt8(size); + } else if (field.length === 2) { + // With an offset of 256 + field.writeUInt16LE(size - 256); + } else if (field.length === 4) { + field.writeUInt32LE(size); + } else if (field.length === 8) { + field.writeBigUInt64LE(declared ?? BigInt(size)); + } + parts.push(field); + blocks.forEach((block, index) => { + const last = index === blocks.length - 1; + if ("raw" in block) { + const bytes = Buffer.from(block.raw); + parts.push(blockHeader(bytes.length, 0, last), bytes); + } else { + parts.push(blockHeader(block.count, 1, last), Buffer.from(block.rle)); + } + }); + return Buffer.concat(parts); +} + +/** A skippable frame (its magic number ends with `nibble`) */ +function skippable(nibble: number, data: Buffer): Buffer { + const header = Buffer.alloc(8); + header.writeUInt32LE(0x184d2a50 + nibble, 0); + header.writeUInt32LE(data.length, 4); + return Buffer.concat([header, data]); +} + +/** A compressed frame, with a checksum */ +function compressed(input: Buffer | string): Buffer { + return zstdCompressSync(input, { + params: { [constants.ZSTD_c_checksumFlag]: 1 }, + }); +} + +/** The chunks of a body: `size` bytes each (the last one may be shorter) */ +function pieces(body: Buffer, size: number): Buffer[] { + const list: Buffer[] = []; + for (let offset = 0; offset < body.length; offset += size) { + list.push(body.subarray(offset, offset + size)); + } + return list; +} + +/** Sends one request through a parser of `zstd` (then `echo`) */ +async function post( + body: IBody, + coding = "zstd", + parser = new BodyParser({ zstd: true }), +): Promise { + const server = await serve(parser); + try { + return await send(server.url, body, { + "Content-Type": ct_text, + "Content-Encoding": coding, + }); + } finally { + await server.close(); + } +} + +/** A `200` reply of a text */ +function parsed(value: string): IReply { + return { + status: 200, + connection: "keep-alive", + body: { type: "text", value }, + }; +} + +/** 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()); +} + +const refused: IReply = { status: 400, connection: "close", body: null }; + +/** Frames of every kind of header and block, and their content */ +const crafted: readonly [Buffer, string][] = [ + [compressed(hello.repeat(20)), hello.repeat(20)], + [frame([{ raw: "ab" }]), "ab"], + [ + frame([{ raw: "ab" }, { rle: "c", count: 3 }, { raw: "" }], { + single: false, + }), + "abccc", + ], + [frame([{ raw: "x".repeat(300) }], { content: 1 }), "x".repeat(300)], + [frame([{ raw: "cd" }], { content: 2 }), "cd"], + [frame([{ raw: "ef" }], { content: 3 }), "ef"], + [frame([{ raw: "gh" }], { dictionary: 1 }), "gh"], + [frame([{ raw: "ij" }], { dictionary: 2 }), "ij"], + [ + frame([{ rle: "k", count: 4 }, { raw: "l" }], { + single: false, + dictionary: 3, + content: 2, + }), + "kkkkl", + ], + [zstdCompressSync(""), ""], +]; + +/** Every crafted frame, between skippable frames */ +const all = Buffer.concat([ + skippable(0, Buffer.from("abc")), + ...crafted.slice(0, 4).map(([bytes]) => bytes), + // An empty skippable frame between two frames + skippable(15, Buffer.alloc(0)), + ...crafted.slice(4).map(([bytes]) => bytes), + skippable(7, Buffer.from("trailer")), +]); +const content = crafted.map(([, text]) => text).join(""); + +suite("BodyParser (zstd)", () => { + test("concatenated frames are one body, wherever the chunks end", async () => { + // In one chunk (each frame is given to a decoder of its own) + deepStrictEqual(await post(all), parsed(content)); + // Every frame, block and field split, the magic numbers included + for (const size of [1, 2, 3, 5, 7, 13]) { + deepStrictEqual( + await post(chunked(...pieces(all, size))), + parsed(content), + `${size} bytes per chunk`, + ); + } + }); + + test("skippable frames are ignored, an empty frame is an empty body", async () => { + const only = Buffer.concat([ + skippable(0, Buffer.from("abc")), + skippable(1, Buffer.alloc(0)), + ]); + deepStrictEqual(await post(only), parsed("")); + deepStrictEqual(await post(zstdCompressSync("")), parsed("")); + deepStrictEqual( + await post(Buffer.concat([zstdCompressSync(""), only])), + parsed(""), + ); + }); + + test("a body without a byte is a 400 for every coding, an empty content is not", async () => { + const parser = new BodyParser({ zstd: true }); + for (const [coding, empty] of [ + ["zstd", zstdCompressSync("")], + ["gzip", gzipSync("")], + ["x-gzip", gzipSync("")], + ["deflate", deflateSync("")], + ["br", brotliCompressSync("")], + ["zstd, gzip", gzipSync(zstdCompressSync(""))], + ["gzip, zstd", zstdCompressSync(gzipSync(""))], + ] as const) { + deepStrictEqual(await post("", coding, parser), refused, coding); + deepStrictEqual(await post(chunked(), coding, parser), refused, coding); + deepStrictEqual(await post(empty, coding, parser), parsed(""), coding); + } + }); + + test("a frame that is truncated or damaged is a 400, also after a valid one", async () => { + const valid = compressed(hello); + const next = compressed(hello.repeat(3)); + const corrupt = Buffer.from(next); + // Its checksum + corrupt[corrupt.length - 1] ^= 0xff; + const garbled = Buffer.from(next); + // Its first block + garbled.fill(0xff, 9, 13); + const reserved = Buffer.concat([MAGIC, Buffer.from([0x20, 2])]); + const large = await largeWindow(); + for (const [name, damaged] of [ + ["part of a magic number", MAGIC.subarray(0, 3)], + ["a frame header", next.subarray(0, 5)], + ["a block header", next.subarray(0, 7)], + ["a block", next.subarray(0, 12)], + ["a checksum", next.subarray(0, next.length - 1)], + ["a skippable header", skippable(2, Buffer.alloc(4)).subarray(0, 6)], + ["a skippable frame", skippable(2, Buffer.alloc(4)).subarray(0, 10)], + ["data that is not a frame", Buffer.from("junk")], + ["zero padding", Buffer.alloc(8)], + ["a reserved block", Buffer.concat([reserved, blockHeader(2, 3, true)])], + ["a wrong checksum", corrupt], + ["a window larger than 8 MiB", large], + ["corrupt data", garbled], + ] as const) { + const body = Buffer.concat([valid, damaged]); + deepStrictEqual(await post(body), refused, `whole: ${name}`); + deepStrictEqual( + await post(chunked(...pieces(body, 4))), + refused, + `in chunks: ${name}`, + ); + // As the first frame too + deepStrictEqual(await post(damaged), refused, `first: ${name}`); + } + }); + + test("`limit` counts the content of every frame, `encoded_limit` the body", async () => { + const twice = Buffer.concat([compressed(hello), compressed(hello)]); + const tooLarge: IReply = { status: 413, connection: "close", body: null }; + // 11 bytes per frame + deepStrictEqual( + await post(twice, "zstd", new BodyParser({ zstd: true, limit: 21 })), + tooLarge, + ); + deepStrictEqual( + await post(twice, "zstd", new BodyParser({ zstd: true, limit: 22 })), + parsed(hello.repeat(2)), + ); + // Without a `Content-Length`: counted as the body is read + const encoded_limit = twice.length - 1; + deepStrictEqual( + await post( + chunked(...pieces(twice, 8)), + "zstd", + new BodyParser({ zstd: true, encoded_limit }), + ), + tooLarge, + ); + }); + + test("`verify` gets the body as received once, and its content", async () => { + const received: Buffer[] = []; + const parser = new BodyParser({ + zstd: true, + verify_encoded: true, + verify: (decoded: Buffer, _context: Context, { encoded }): void => { + ok(encoded !== null); + received.push(encoded, decoded); + }, + }); + deepStrictEqual( + await post(chunked(...pieces(all, 9)), "zstd", parser), + parsed(content), + ); + deepStrictEqual(received, [all, Buffer.from(content)]); + }); + + test("frames within and around other codings", async () => { + const parser = new BodyParser({ zstd: true }); + const frames = Buffer.concat([compressed("ab"), compressed("cd")]); + const gzipped = gzipSync("abcd"); + const half = gzipped.length >> 1; + for (const [coding, body] of [ + ["zstd, gzip", gzipSync(frames)], + [ + "gzip, zstd", + Buffer.concat([ + compressed(gzipped.subarray(0, half)), + zstdCompressSync(gzipped.subarray(half)), + ]), + ], + [ + "zstd, zstd", + Buffer.concat([ + zstdCompressSync(frames.subarray(0, 5)), + zstdCompressSync(frames.subarray(5)), + ]), + ], + ] as const) { + deepStrictEqual(await post(body, coding, parser), parsed("abcd"), coding); + } + }); + + test( + "a large body through two codings, with backpressure", + { timeout: 20_000 }, + async () => { + // Incompressible: the frames are about as large as their content + const data = randomBytes(1 << 20); + const gzipped = gzipSync(data); + const third = Math.floor(gzipped.length / 3); + const body = Buffer.concat([ + zstdCompressSync(gzipped.subarray(0, third)), + skippable(3, Buffer.alloc(16)), + zstdCompressSync(gzipped.subarray(third)), + ]); + const parser = new BodyParser({ zstd: true, raw: true }); + const server = await listen( + new Binden().use(parser, (context: Context) => + context.json({ + same: data.equals(context.request.body as Buffer), + }), + ), + ); + try { + const reply = await send(server.url, chunked(...pieces(body, 65_536)), { + "Content-Type": "application/octet-stream", + "Content-Encoding": "gzip, zstd", + }); + deepStrictEqual(reply, { + status: 200, + connection: "keep-alive", + body: { same: true }, + }); + } finally { + await server.close(); + } + }, + ); + + test("a frame whose content is not of its declared size is a 400, in one write and in chunks", async () => { + const parser = new BodyParser({ zstd: true }); + // Ended by an empty block, whose end the decoder of Node.js does not + // check against the declared size when it gets the frame in pieces + const sized = (text: string, header: IHeader): Buffer => + frame([{ raw: text }, { raw: "" }], { single: false, ...header }); + const unsized = frame([{ raw: "xyz" }], { single: false }); + for (const [name, body] of [ + // R3's reproduction (RFC 8878 offsets a size of 2 bytes by 256) + [ + "declares 300 bytes, has 3", + sized("abc", { content: 1, declared: 300n }), + ], + [ + "declares 256 bytes, has 600", + sized("a".repeat(600), { content: 1, declared: 256n }), + ], + // Sizes that compensate each other: [3, 2] declared, [2, 3] decoded + [ + "compensating sizes", + Buffer.concat([ + sized("ab", { content: 2, declared: 3n }), + sized("cde", { content: 2, declared: 2n }), + ]), + ], + [ + "compensating sizes, the other way", + Buffer.concat([ + sized("abc", { content: 2, declared: 2n }), + sized("de", { content: 2, declared: 3n }), + ]), + ], + [ + "after a frame without a size", + Buffer.concat([unsized, sized("ab", { content: 2, declared: 3n })]), + ], + [ + "before a frame without a size", + Buffer.concat([sized("ab", { content: 2, declared: 3n }), unsized]), + ], + // A size of 8 bytes is read whole: its low 4 bytes are 3 + [ + "8 bytes: 2^32 + 3", + sized("abc", { content: 3, declared: 2n ** 32n + 3n }), + ], + [ + "8 bytes: far above any limit", + sized("abc", { content: 3, declared: 2n ** 64n - 1n }), + ], + // Whose window is its declared size + [ + "8 bytes: a single segment far above any limit", + frame([{ raw: "abc" }], { content: 3, declared: 2n ** 63n }), + ], + ] as const) { + for (const size of [body.length, 4, 1]) { + deepStrictEqual( + await post(chunked(...pieces(body, size)), "zstd", parser), + refused, + `${name}: ${size} bytes per chunk`, + ); + } + deepStrictEqual(await post(body, "zstd", parser), refused, name); + } + for (const [body, text] of [ + [frame([{ raw: "abc" }]), "abc"], + [sized("x".repeat(300), { content: 1 }), "x".repeat(300)], + [ + Buffer.concat([ + sized("ab", { content: 2 }), + unsized, + sized("cde", { content: 2 }), + ]), + "abxyzcde", + ], + [sized("abc", { content: 3 }), "abc"], + ] as const) { + for (const size of [body.length, 4, 1]) { + deepStrictEqual( + await post(chunked(...pieces(body, size)), "zstd", parser), + parsed(text), + `${text}: ${size} bytes per chunk`, + ); + } + } + }); + + test("the reading of a frame stops at the deadline", async () => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const parser = new BodyParser({ zstd: true }); + const server = await listen( + new Binden({ timeout: 100 }).use(async (context: Context) => { + await parser.run(context).catch((error: unknown) => { + settle(error); + throw error; + }); + }, echo), + ); + const client = request({ + host: "127.0.0.1", + port: server.port, + method: "POST", + agent: false, + headers: { + "Content-Type": ct_text, + "Content-Encoding": "zstd", + "Content-Length": 1024, + }, + }); + client.on("error", () => { + // The server closes the connection after its reply + }); + try { + // A frame, and the first bytes of the next one + client.write( + Buffer.concat([compressed(hello), compressed(hello).subarray(0, 8)]), + ); + const [response] = (await within(once(client, "response"))) as [ + IncomingMessage, + ]; + deepStrictEqual( + [response.statusCode, response.headers.connection], + [503, "close"], + ); + response.resume(); + const error = await within(settled); + ok(error instanceof BindenAbortError); + deepStrictEqual(error.reason, "deadline"); + } finally { + client.destroy(); + await server.close(); + } + }); + + test("a client that goes away within a frame", async () => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: started, resolve: start } = Promise.withResolvers(); + const parser = new BodyParser({ zstd: true }); + const server = await listen( + new Binden().use(async (context: Context) => { + start(null); + await parser.run(context).catch((error: unknown) => { + settle(error); + throw error; + }); + }, echo), + ); + const client = request({ + host: "127.0.0.1", + port: server.port, + method: "POST", + agent: false, + headers: { + "Content-Type": ct_text, + "Content-Encoding": "zstd", + "Content-Length": 1024, + }, + }); + client.on("error", () => { + // Closed on purpose + }); + try { + client.write(compressed(hello.repeat(50)).subarray(0, 20)); + await within(started); + // Once the parser has read what has been sent + await sleep(50); + client.destroy(); + const error = await within(settled); + ok(error instanceof BindenError); + deepStrictEqual([error.status, error.code], [400, "aborted"]); + } finally { + await server.close(); + } + }); +}); 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/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..d542516c --- /dev/null +++ b/packages/compression/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/packages/compression/README.md b/packages/compression/README.md new file mode 100644 index 00000000..c78cda0b --- /dev/null +++ b/packages/compression/README.md @@ -0,0 +1,317 @@ +# @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: `br`, `gzip`, `deflate` and (opt-in) `zstd`. + +## Installation + +```bash +npm install @binden/compression +``` + +## Usage + +```typescript +import { Binden } from "binden"; +import compression, { Compression } from "@binden/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()` and `flush()` on the response of each +request, so that every writer (the following middlewares, `send()`, `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. A head that Node.js refuses (e.g. a reason phrase with a line break, +or a `Trailer` of a body that is not chunked) throws as without a compression, and leaves nothing +of the decision, whether the body would be compressed or sent as it is: the compressor is +destroyed, and the status, the headers and the framing of Node.js (e.g. whether it closes the +connection) are those from before, so that the error is replied with a decision of its own. A +body sent as it is goes through the `write()` and `end()` of binden: one that Node.js refuses once +the head is committed (e.g. a `Content-Length` that `strictContentLength` finds wrong) sends nothing +more, and closes the connection without a reply of the error, and a first `write()` past such a +length throws before anything is committed (the error can be replied), as without a compression. + +The arguments of a `write()` or an `end()` are checked first, as binden checks them, and a refused +one throws before anything is decided or changed: + +- a chunk that Node.js refuses (neither a string nor bytes) throws the error of Node.js. As for + Node.js, a falsy chunk of `end()` is none: `end(null)`, `end(0)` and `end(false)` are a plain + `end()`, while `write(null)` throws. Nothing of the socket is changed either (`end()` of Node.js + would leave it corked, and the next reply stalled), also once the response has been replaced + (`406`) or ended through its compressor: it throws as for a response that has not ended, rather + than the `error` event that Node.js emits for an ended one (a response that the application has + ended as it is answers as without a compression, also one destroyed before its headers were + committed, e.g. `end(1)` after `destroy()` and `end()`, which Node.js ignores); +- an unknown encoding (or `buffer` for a string) throws `ERR_UNKNOWN_ENCODING`, described without + being converted (e.g. "Unknown encoding: (an object)"), but for a response whose chunks Node.js + ignores. These are one without a body (to `HEAD`, or with a 1xx, 204 or 304 status), one that is + destroyed, and one that the application has ended (compressed, replaced with `406`, or as it + is); their status is then neither converted nor stored, as without a compression. A replaced + response that the application has not ended still checks it, as one that has not ended; +- a string in `hex`, `base64` or `base64url` (the name in any case) is written as the bytes that + `Buffer.from()` decodes from it: Node.js only estimates its length, which may be more than it + writes (e.g. for invalid digits). The decision (the `threshold`) and the framing of the body + are those of its bytes. An explicit `Content-Length` is never replaced, and one that is neither + a number nor a string (nor an array of them, e.g. `true`, an object whose `toString()` gives a + length, or an array with a hole) is refused before anything is decided, as binden refuses it: + the write throws `ERR_HTTP_INVALID_HEADER_VALUE`, never converted. + +## Options + +| Option | Default | Description | +| --------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `format` | `"auto"` | `"auto"` negotiates the coding with `Accept-Encoding`; `"br"`, `"gzip"`, `"deflate"` or `"zstd"` is the only one offered | +| `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 may be | +| `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. Options that are not an object (or `undefined`) throw +"`options` is not an object: …", and what reading them throws (e.g. a revoked proxy) is formatted, +as by every entry point of binden. The options are read once (also each element of `encodings`, by +its index, `brotli.params` and `zstd.params`, which the compressors get as copies). The decision +reads the `Accept-Encoding`, the method and the HTTP version of the request once, as the +compression is installed. `writeHead()` of a compressed response reads and converts its status and +its headers as binden does (once, as Node.js gets them): a `bigint` status throws as with Node.js +and binden (`Cannot mix BigInt and other types`). +The headers given in pairs set a name by its first value and append the others, as Node.js does, +each value checked once. + +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, once per response, when its representation is chosen (see +[When a response is compressed](#when-a-response-is-compressed)). 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. +That record is written by binden's own path for its records: a method or `child()` of +`context.log` that the application has replaced, or that throws (also its `log` getter), never +runs, and the response is replaced all the same. + +```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 once for each response, when its status and its representation are known: +whatever the following middlewares set before is taken into account. A response to `HEAD` gets the +decision of the response to `GET`, by the same body: the chunk of its `end()` (which Node.js does +not send), or the `Content-Length` of a stream that `send()` does not read for `HEAD` (a stream +without one is a body of unknown length, not an empty one). It is taken when its headers are +committed (by `writeHead()`, `flushHeaders()`, `flush()`, or the first `write()` or `end()`), or +before: + +- `sendFile()`, and `send()` with the dynamic `ETag` of binden, choose the representation of their + body before they evaluate the preconditions of the request (`If-Match`, `If-None-Match`, …), so + that these apply to the representation that is sent (see + [A compressed response](#a-compressed-response)). The choice sends no header and creates no + compressor: the commit of the headers applies it. A reply that replaces that body (e.g. of the + error handler, once the file has failed or the deadline has passed) gets a decision of its own. +- `send()` of a stream that it does not read (for `HEAD`, or a status without a body) chooses the + representation of the `Content-Length` of the response (of an unknown length without one). + +So what the decision reads (the status, `Content-Type`, `Content-Encoding`, `Content-Length`, +`Cache-Control` and `skipCompression()`) must be set before the reply starts (`send()`, +`sendFile()`, or a first write): a later change may not be taken into account. Once the work of a +request may no longer change its response (e.g. abandoned at its deadline), `skipCompression()` +and the `run()` of a compression are ignored, as its other changes are. + +The compression decides by the status as binden does: the integer that Node.js commits (`| 0`, e.g. +`204.5` is a `204`), converted once (an object or a string assigned to `statusCode` is converted by +binden, and is that number from then on), never compared as it is. So a `write()` or an `end()` in +an unknown encoding of a `204` or a `304`, also of an object or a string status, ignores its chunk, +as plain Node.js and binden alone do, and a retry after a reason phrase that Node.js refuses does not +convert the status again. + +A response without a body is sent as it is, whatever the request accepts: a `1xx`, `204` or `205` +status, or an empty body (`Content-Length: 0`, e.g. of a `304` that stands for an empty `200`). Any +other response depends on the `Accept-Encoding` of the request (`Vary: Accept-Encoding`, see +[Content negotiation](#content-negotiation)), and may be compressed unless: + +- 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`. + +A response that may not be compressed is sent as it is when the request accepts it, and replaced +with `406 Not Acceptable` otherwise (also with a fixed `format`). The compression leaves a response +alone only when it ran once its headers had been sent. + +```typescript +import { Binden, type Context } from "binden"; +import compression, { skipCompression } from "@binden/compression"; + +// A response that is never compressed: before its reply starts +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 + +The representation 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 every response with a body: + +- the response is compressed with the coding of `encodings` (with `format: "auto"`), or the fixed + `format` (the only one offered then), of the greatest weight (its own `q`, or the one of `*`; + `x-gzip` is `gzip`), the earlier one of `encodings` on a tie, unless `identity` weighs more. + Browsers (`gzip, deflate, br, zstd`) get `br`. A coding with `q=0`, or that the header does not + name (without `*`), is not acceptable; +- without `Accept-Encoding`, or with an empty one, the response is not compressed (any coding is + acceptable without the header, but the response as it is suits a client that does not ask for + one); +- `identity` (the response as it is) is acceptable unless refused (`identity;q=0`, or `*;q=0` + without `identity`). When nothing is acceptable, the response is replaced with + `406 Not Acceptable`: an empty body (`Content-Length: 0`) without the headers of the body that + it replaces, as an error reply of binden (its type, framing with `Trailer`, validators, + digests, …), nor `Accept-Ranges`. What the application writes afterwards is discarded (the + callbacks of its writes still run, as for written chunks, the callback of its `end()` as the one + of the end of the replacement, and a refused chunk or encoding still throws, and binden is told + of each discarded write and `end()`, so that a send whose bytes are gone inside that write (e.g. + detached by the `filter`) is not cut for them, as they are no part of the `406`); +- an encoded response (e.g. a precompressed file, with the `Content-Encoding` that the application + has set) has no other version: it is sent as it is when the request accepts its coding (any + coding without `Accept-Encoding`, none with an empty one), and replaced with `406` otherwise. + +A fixed `format` offers one coding, so that a proxy that negotiates can ask for it (e.g. `zstd`, +which `auto` does not offer by default): a request that does not accept it gets the response as it +is (or `406`). + +### A compressed response + +- `Content-Encoding` names the coding; `Content-Length` and `Accept-Ranges` are removed (ranges of + the compressed body are not served), as are the digests of the uncompressed body + (`Content-Digest`, `Repr-Digest` and `Digest`, + [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html)), which the compressed body does not + match. The body is sent in chunks + (`Transfer-Encoding: chunked`, also on the headers of `HEAD`), or to an HTTP/1.0 client delimited + by closing the connection (`Connection: close`, and never a `Transfer-Encoding`): a stale framing + header (e.g. copied from an upstream response) neither closes a connection that may be kept alive + nor sends chunks to a client that does not know them; +- 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`, which + [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.1) compares strongly. + `sendFile()` and the dynamic `ETag` of binden evaluate the preconditions against the compressed + representation: a strong `If-Match` (e.g. the `validator` of a file) fails with an empty + `412 Precondition Failed`, without `Content-Encoding` (nothing is compressed), while + `If-Match: *` matches any representation. A request that must meet a strong `If-Match` accepts + the uncompressed representation only (`Accept-Encoding: identity`); +- `sendFile()` sends a compressed file whole: `Range` (and `If-Range`) is ignored, as the ranges + would be of the compressed bytes. The uncompressed file keeps its ranges (`206`, `416`); +- 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 (as a response to `HEAD` gets the one of `GET`): 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 + its `Content-Length`, the length of that `200` (binden keeps it on the `304` of `sendFile()` and of + its dynamic `ETag`); a `304` without one gets the decision of 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, and `writableEnded` is set by `end()`. Each callback runs + once, as the work that has called `write()` or `end()` (not as the one that has started the + compressor): the callback of `write()` once the compressor has taken its chunk, with its error if + any (and no receiver, as the callback of a stream), the callback of `end()` once the response has + finished, as Node.js calls it without a compression (its receiver and its arguments depend on the + version of Node.js). A response closed early (e.g. the client goes away, or the deadline cuts a + slow client) destroys the compressor: on the next tick, the callbacks of the writes that it has + not taken yet, and of an `end()` that it has not finished (or that it has given to a response + destroyed in the meantime, which Node.js does not call back), get the error of the response (or + `ERR_STREAM_DESTROYED`), so that a middleware that awaits them resumes. None is called again + afterwards (e.g. by the compressor that completes the write in progress). A `send()` (or + `sendFile()`) whose chunk waits in the compressor rejects with that error, as it rejects with the + error of the socket without a compression. A response destroyed before its headers are committed + (e.g. the client has gone away while a middleware waited for a query) gets no compressor: it is + left as it is, and the callback of a `write()` gets `ERR_STREAM_DESTROYED` on the next tick, as + without a compression. + +## `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. + +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: + +```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); +``` + +## zstd + +`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); +``` diff --git a/packages/compression/index.ts b/packages/compression/index.ts new file mode 100644 index 00000000..ab87f5e1 --- /dev/null +++ b/packages/compression/index.ts @@ -0,0 +1,303 @@ +import { + BindenResponse, + type ContentType, + type Context, + type IMiddlewareParams, + Middleware, + described, + formatStack, + formatting, +} from "binden"; +import { CompressedResponse, type ISettings, skip } from "./src/response.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"; + +/** The `format` of a compression: `auto` negotiates it, the others are fixed */ +export type ICompressFormats = ICompressEncodings | "auto"; + +/** The options of a `Compression` */ +export interface ICompressionOptions extends IMiddlewareParams { + /** + * The content coding: `auto` (the default) negotiates it with + * `Accept-Encoding` among `encodings`; a fixed one is the only coding + * offered (the request must accept it) + */ + 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 once, when its representation is chosen + * (see the README): 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; + /** 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]; + +const defaultEncodings: readonly ICompressEncodings[] = [ + "br", + "gzip", + "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 formatStack(new TypeError(`\`${name}\` is not an object`)); + } +} + +/** + * The codings of `encodings`: a non-empty list of distinct codings, read once + * (by its indexes), whose copy is checked and kept + */ +function checkEncodings(encodings: unknown): readonly ICompressEncodings[] { + const copy: unknown[] = []; + if (Array.isArray(encodings)) { + const { length } = encodings; + for (let index = 0; index < length; index += 1) { + copy.push(encodings[index]); + } + } + if ( + copy.length === 0 || + !copy.every((coding) => codings.includes(coding as ICompressEncodings)) || + new Set(copy).size !== copy.length + ) { + throw formatStack( + new TypeError( + "`encodings` is not a non-empty list of distinct codings (br, deflate, gzip, zstd)", + ), + ); + } + return Object.freeze(copy as ICompressEncodings[]); +} + +/** The options of a `Compression`, each read once (see `readOptions()`) */ +interface IReadOptions { + readonly format: ICompressFormats; + readonly encodings: readonly ICompressEncodings[] | undefined; + readonly threshold: number; + readonly filter: ICompressionFilter; + readonly gzip: ZlibOptions; + readonly deflate: ZlibOptions; + readonly brotli: BrotliOptions; + readonly zstd: ZstdOptions; + readonly rest: IMiddlewareParams; +} + +/** + * Reads the options of a `Compression` once, with their defaults: anything but + * an object (or `undefined`, the defaults) is refused with a `TypeError` + * before anything is read, which describes it (see `described()`) + */ +function readOptions(options: unknown): IReadOptions { + if (typeof options !== "object" || options === null) { + throw formatStack( + new TypeError(`\`options\` is not an object: ${described(options)}`), + ); + } + const { + format = "auto", + encodings, + threshold = 1024, + filter = (_context: Context, type: ContentType | null): boolean => + isCompressible(type), + gzip = {}, + deflate = {}, + brotli = {}, + zstd = {}, + ...rest + } = options as ICompressionOptions; + return { + format, + encodings, + threshold, + filter, + gzip, + deflate, + brotli, + zstd, + rest, + }; +} + +/** + * Compresses the responses (`br`, `gzip`, `deflate` and `zstd`). The decision + * is taken once for each response, before its preconditions are evaluated or + * when its headers are committed: see the README. + */ +export class Compression extends Middleware { + readonly #settings: ISettings; + + /** + * Validates the options: a `TypeError` (or `RangeError`) for an invalid one, + * also for the options of a compressor (one is created to check them), or + * for `zstd` when `node:zlib` does not have it. What reading them throws + * (e.g. a revoked proxy, or a getter that throws) is formatted (see + * `formatting()`). + */ + public constructor(options: ICompressionOptions = {}) { + const read = formatting(() => readOptions(options)); + super(read.rest); + this.#settings = formatting(() => Compression.#settingsOf(read)); + } + + /** The settings of the options (see `IReadOptions`), validated */ + static #settingsOf({ + format, + encodings, + threshold, + filter, + gzip, + deflate, + brotli, + zstd, + }: IReadOptions): ISettings { + if (!formats.includes(format)) { + throw formatStack( + new TypeError( + "`format` is not one of auto, br, deflate, gzip and zstd", + ), + ); + } else if (typeof encodings !== "undefined" && format !== "auto") { + throw formatStack( + new TypeError("`encodings` applies to the `auto` format only"), + ); + } else if (typeof threshold !== "number") { + throw formatStack(new TypeError("`threshold` is not a number")); + } else if (!Number.isSafeInteger(threshold) || threshold < 0) { + throw formatStack( + new RangeError("`threshold` is not a non-negative integer"), + ); + } else if (typeof filter !== "function") { + throw formatStack(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 + : checkEncodings(encodings); + const used = format === "auto" ? offered : [format]; + if (used.includes("zstd") && !hasZstd()) { + throw formatStack( + new TypeError("zstd is not supported by this version of Node.js"), + ); + } + + // Each read once: `params` of `brotli` is copied with its own defaults, + // and the one of `zstd` as it is (`node:zlib` reads it for every + // compressor, which must not read the object of the application) + const { params, ...others } = brotli; + const { params: zstdParams, ...zstdOthers } = zstd; + const compressors: ICompressorOptions = Object.freeze({ + br: { + ...others, + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 4, + ...params, + }, + }, + deflate: { ...deflate }, + gzip: { ...gzip }, + zstd: { ...zstdOthers, params: { ...zstdParams } }, + }); + // Invalid options of a compressor throw now, rather than with a request + for (const coding of used) { + createCompressor(coding, compressors).close(); + } + + return Object.freeze({ + format, + encodings: offered, + threshold, + filter, + compressors, + }); + } + + /** The `format` of the compression */ + public get format(): ICompressFormats { + return this.#settings.format; + } + + /** + * Installs the compression on the response, unless its headers have been + * sent (it is then left as it is), a compression is installed on it + * already, or the work that runs it may no longer mutate the response + * (e.g. once its deadline has passed): the decision is taken when its + * representation is chosen (by `sendFile()`, the dynamic `ETag` of binden + * and a `send()` that does not read its stream), or else when its headers + * are committed + */ + public run(context: Context): void { + // What reading the context throws is formatted (see `formatStack()`) + try { + CompressedResponse.install(context, this.#settings); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } +} + +/** + * Leaves one response uncompressed (e.g. a stream that must not wait in a + * compressor): call it before its reply starts (`send()`, `sendFile()`, or a + * first write), which may choose its representation at once. Ignored once the + * work that calls it may no longer mutate the response. + */ +export function skipCompression(response: BindenResponse): void { + // What reading it throws (e.g. a revoked proxy) is formatted (see + // `formatting()`) + formatting(() => { + if (!(response instanceof BindenResponse)) { + throw formatStack( + 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 new file mode 100644 index 00000000..d11b2b46 --- /dev/null +++ b/packages/compression/package.json @@ -0,0 +1,68 @@ +{ + "name": "@binden/compression", + "version": "0.0.0-development", + "description": "binden compression middleware", + "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" + }, + "sideEffects": false, + "files": [ + "dist", + "!dist/test", + "index.ts", + "src" + ], + "engines": { + "node": ">=24.14.1" + }, + "scripts": { + "build": "tsc", + "prebuild": "node ../../scripts/clean.mjs", + "prepack": "npm run build", + "test": "node ../../scripts/test.mjs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/compression" + }, + "keywords": [ + "compress", + "compression", + "br", + "brotli", + "gzip", + "deflate", + "zstd", + "content-encoding", + "middleware", + "HTTP", + "binden" + ], + "author": "Sergey Bakulin ", + "license": "MIT", + "bugs": { + "url": "https://github.com/binden-js/binden/issues" + }, + "homepage": "https://github.com/binden-js/binden/tree/main/packages/compression#readme", + "peerDependencies": { + "binden": "^0.0.0-development" + }, + "devDependencies": { + "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/src/codings.ts b/packages/compression/src/codings.ts new file mode 100644 index 00000000..c914083a --- /dev/null +++ b/packages/compression/src/codings.ts @@ -0,0 +1,71 @@ +import zlib, { + type BrotliOptions, + type ZlibOptions, + type ZstdOptions, +} from "node:zlib"; +import type { Transform } from "node:stream"; + +/** 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", +]; + +/** 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; +} + +/** 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, with its options */ +export function createCompressor( + coding: ICompressEncodings, + options: ICompressorOptions, +): ICompressor { + if (coding === "br") { + return zlib.createBrotliCompress(options.br); + } else if (coding === "deflate") { + return zlib.createDeflate(options.deflate); + } else if (coding === "gzip") { + return zlib.createGzip(options.gzip); + } + 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/filter.ts b/packages/compression/src/filter.ts new file mode 100644 index 00000000..bdcca59e --- /dev/null +++ b/packages/compression/src/filter.ts @@ -0,0 +1,49 @@ +import { type ContentType, type Context, formatting } 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 { + // What reading it throws (e.g. of a value that is not a `ContentType`) is + // formatted (see `formatting()`) + return formatting(() => { + 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..a7bcbaf7 --- /dev/null +++ b/packages/compression/src/negotiate.ts @@ -0,0 +1,76 @@ +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"; +} + +/** + * Whether the request accepts a representation of these content codings (RFC + * 9110, section 12.5.3): any without `Accept-Encoding`, otherwise each one + * that the header names (`x-gzip` is `gzip`, unknown codings count too), or + * `*`, with a weight above 0. None with an empty header, nor an invalid + * coding (e.g. `*`). + */ +export function accepts( + header: string | undefined, + codings: readonly string[], +): boolean { + return ( + typeof header === "undefined" || + codings.every((coding) => { + try { + return AcceptEncoding.negotiate([coding], header) !== false; + } catch { + // Not a content coding + return false; + } + }) + ); +} diff --git a/packages/compression/src/response.ts b/packages/compression/src/response.ts new file mode 100644 index 00000000..cc9ba87e --- /dev/null +++ b/packages/compression/src/response.ts @@ -0,0 +1,1384 @@ +import { + type BindenRequest, + type BindenResponse, + ContentType, + type Context, + type IFramingState, + type IRepresentation, + type IResponseOperation, + bodyHeaders, + described, + formatStack, + framingLength, + kDiscardWrite, + kListen, + kNegotiator, + kOperation, + kRestoreFraming, + kSaveFraming, + kUnlisten, + kValidateHead, + logTo, +} from "binden"; +import { + type ICompressEncodings, + type ICompressor, + type ICompressorOptions, + createCompressor, + flushKind, +} from "./codings.js"; +import { + type OutgoingHttpHeader, + type OutgoingHttpHeaders, + OutgoingMessage, + STATUS_CODES, +} from "node:http"; +import { accepts, negotiate } from "./negotiate.js"; +import type { ICompressionFilter } from "./filter.js"; +import { isUint8Array } from "node:util/types"; + +/** 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; +} + +/** + * 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); `vary` when it depends on `Accept-Encoding` + */ +type IOutcome = + | { readonly kind: "as_is"; readonly vary: boolean } + | { + readonly kind: "compress"; + readonly coding: ICompressEncodings; + readonly vary: boolean; + } + | { 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; + +/** A response without a body, whatever the request accepts */ +const AS_IS: IOutcome = { kind: "as_is", vary: false }; + +/** The response as it is, which the request accepts */ +const AS_IS_VARY: IOutcome = { kind: "as_is", vary: true }; + +/** No representation is acceptable */ +const NOT_ACCEPTABLE: IOutcome = { kind: "replace", status: 406 }; + +/** The `filter` has failed */ +const FAILED: IOutcome = { kind: "replace", status: 500 }; + +/** + * The digests of the body as it is sent (RFC 9530, and `Digest` of RFC + * 3230), which a coding changes + */ +const digests = ["Content-Digest", "Digest", "Repr-Digest"]; + +/** + * The headers that an empty replacement does not have: those of the body that + * it replaces, as for an error reply of binden (its framing, with `Trailer`, + * its representation and its validators), and `Accept-Ranges`, as it has no + * ranges either + */ +const replaced = [...bodyHeaders, "Accept-Ranges"]; + +/** 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 !== ""); +} + +/** + * The `Content-Length` of a response as the compression decides by it, as + * binden frames by it (see `framingLength()`), `null` without a valid one: a + * number, a string, or an array of them. A value of another type (or an + * element, also a hole of an array, e.g. `true`, or an object whose + * `toString()` gives a length), which Node.js would send converted, throws + * the `TypeError` of binden (`ERR_HTTP_INVALID_HEADER_VALUE`) before anything + * is decided: never converted, never read as no length + */ +function lengthOf(response: IResponse): number | null { + return framingLength(response.getHeader("Content-Length")); +} + +/** 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"); +} + +/** + * Whether `write()` and `end()` of Node.js accept a chunk: a string or a + * `Uint8Array` (e.g. a `Buffer`), as they check it before anything else + */ +function isChunk(chunk: unknown): chunk is Uint8Array | string { + return typeof chunk === "string" || isUint8Array(chunk); +} + +/** + * Throws the error that `write()` and `end()` of Node.js throw for a chunk + * that they refuse (see `isChunk()`), as a response that has not ended + * throws it: by `write()` of a message of its own, which checks the chunk + * before anything else, so that nothing of the response is changed + */ +function refuse(chunk: unknown): never { + return new OutgoingMessage().write(chunk) as never; +} + +/** + * The encoding of a chunk as binden checks it (see `write()` of + * `BindenResponse`): `utf8` without one (a callback in its place, or a falsy + * value, as Node.js reads it), and `null` for one that it refuses (an unknown + * encoding, or `buffer` for a string) + */ +function encodingOf( + chunk: Uint8Array | string, + encoding: unknown, +): BufferEncoding | null { + const given = typeof encoding !== "function" && Boolean(encoding); + if (!given) { + return "utf8"; + } + return typeof encoding === "string" && + (Buffer.isEncoding(encoding) || + (typeof chunk !== "string" && encoding === "buffer")) + ? (encoding as BufferEncoding) + : null; +} + +/** + * The arguments of the check of binden of a head (see `kValidateHead`) by + * which it examines the status code alone: a valid reason phrase, and a valid + * `Content-Length` in place of the ones set (which the commit checks) + */ +const statusAlone = ["", ["Content-Length", 0]] as const; + +/** + * The status code of a response whose head is not committed, as binden + * commits it (see `#converted()` of `BindenResponse`): the integer that + * Node.js commits, stored as the status of the response from then on, as + * Node.js stores it (before it checks anything else, e.g. the encoding of a + * chunk, or the reason phrase), and before the framing is saved (see + * `#commit()`): never compared as it is, which would convert it again, and a + * rollback restores that number. A number is truncated as Node.js truncates + * it (`| 0`, e.g. `204.5` is a `204`), which leaves an integer as it is. Any + * other value (e.g. an object or a string), and a number out of range once + * truncated (e.g. `1000`), is converted once by the conversion of binden as + * Node.js converts it (see `kValidateHead`), which refuses it as Node.js + * refuses it (`ERR_HTTP_INVALID_STATUS_CODE`, by the value given). + */ +function statusOf(response: IResponse): number { + const status: unknown = response.statusCode; + let code = typeof status === "number" ? status | 0 : 0; + if (typeof status !== "number" || code < 100 || code > 999) { + [code] = response[kValidateHead](status, ...statusAlone); + } + response.statusCode = code; + return code; +} + +/** + * Whether Node.js writes the body of a response, as binden reads it (see + * `write()` of `BindenResponse`): not to `HEAD`, nor with a 1xx, 204 or 304 + * status (converted once, see `statusOf()`), whose chunks Node.js ignores + * (and binden does not check) + */ +function hasBody(response: IResponse): boolean { + if (Reflect.get(response, "_hasBody") !== true) { + return false; + } else if (response.headersSent) { + return true; + } + const status = statusOf(response); + return !((status >= 100 && status < 200) || status === 204 || status === 304); +} + +/** + * The error of an encoding that binden refuses (see `encodingOf()`), as binden + * throws it: it describes the encoding without converting it (see + * `described()`) + */ +function unknownEncoding(encoding: unknown): Error { + return Object.assign( + formatStack(new TypeError(`Unknown encoding: ${described(encoding)}`)), + { code: "ERR_UNKNOWN_ENCODING" }, + ); +} + +/** + * The encodings whose byte length Node.js only estimates from the length of + * a string (see `normalized()`) + */ +const estimated: ReadonlySet = new Set(["base64", "base64url", "hex"]); + +/** + * A chunk as it is written: a string (but an empty one, which `end()` takes + * for none) in an encoding whose byte length Node.js only estimates (`hex`, + * `base64` and `base64url`, the name in any case) becomes the bytes that + * `Buffer.from()` decodes. Node.js would otherwise decide by, and frame, a + * length that is not the one that it writes for an invalid string (e.g. + * `end("zz", "hex")` declares one byte, and writes none), and Node.js 24 + * aborts on a `hex` string of an odd length. + */ +function normalized( + chunk: Uint8Array | string, + encoding: unknown, +): Uint8Array | string { + return typeof chunk === "string" && + chunk !== "" && + typeof encoding === "string" && + estimated.has(encoding.toLowerCase()) + ? Buffer.from(chunk, encoding as BufferEncoding) + : chunk; +} + +/** + * The arguments of a `write()` or an `end()` with their chunk as it is + * written (see `normalized()`): a chunk that becomes bytes has no encoding + * any more, and keeps its callback + */ +function normalize(args: readonly unknown[]): readonly unknown[] { + const [chunk, encoding, callback] = args; + if (isChunk(chunk)) { + const data = normalized(chunk, encoding); + if (data !== chunk) { + return typeof callback === "function" ? [data, callback] : [data]; + } + } + return args; +} + +/** + * 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. They are the copies that binden has read once + * and checked (see `kValidateHead`): a list is of pairs. + */ +function setGiven( + response: IResponse, + given: OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined, +): void { + if (Array.isArray(given)) { + const flat: unknown[] = Array.isArray(given[0]) ? given.flat() : given; + 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); + } + // The first values of a name set it, as `appendHeader()` of Node.js + // does for a header that is not set, but checked (and converted) once + const names = new Set(); + for (const [name, value] of pairs) { + if (name && names.has(name.toLowerCase())) { + response.appendHeader(name, value as string[] | string); + } else if (name) { + names.add(name.toLowerCase()); + response.setHeader(name, value); + } + } + } else if (given) { + for (const [name, value] of Object.entries(given)) { + if (name) { + response.setHeader(name, value as OutgoingHttpHeader); + } + } + } +} + +/** 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 }; + } +} + +/** Calls the callback (if any) of an ignored call once, asynchronously and without an error */ +function ignore(args: readonly unknown[]): void { + const callback = args.find((arg) => typeof arg === "function") as + (() => void) | undefined; + if (callback) { + process.nextTick(callback); + } +} + +/** The error of a write after `end()` */ +function writeAfterEnd(): Error { + return Object.assign(formatStack(new Error("write after end")), { + code: "ERR_STREAM_WRITE_AFTER_END", + }); +} + +/** + * The error of a `write()` or an `end()` whose response is destroyed before + * it has been written, or has ended + */ +function destroyed(method: "end" | "write"): Error { + return Object.assign( + formatStack( + new Error(`Cannot call ${method} after a stream was destroyed`), + ), + { code: "ERR_STREAM_DESTROYED" }, + ); +} + +/** + * A callback of a `write()` or an `end()` that runs as the work that has + * called it (the `operation` of its call, see `kOperation`), as binden runs + * it, with the receiver and the arguments that it is called with: the + * compressor would otherwise call it as the work that has created the + * compressor + */ +function owned(callback: ICallback, operation: IResponseOperation): ICallback { + return function ownedCallback(this: unknown, ...args: unknown[]): void { + operation.run(() => { + Reflect.apply(callback, this, args); + }); + }; +} + +/** + * A callback that runs at most once: it is kept in `pending` until it is + * called, and every later call is ignored (e.g. by the compressor once it has + * been called with an error, see `#abandon()`) + */ +function pendingOnce(callback: ICallback, pending: Set): ICallback { + function once(this: unknown, ...args: unknown[]): void { + if (pending.delete(once)) { + Reflect.apply(callback, this, args); + } + } + pending.add(once); + return once; +} + +/** A callback that gives `fn` the receiver and the arguments that it is called with */ +function relay(fn: (receiver: unknown, args: unknown[]) => void): ICallback { + return function relayed(this: unknown, ...args: unknown[]): void { + fn(this, args); + }; +} + +/** + * Leaves one response uncompressed: called before its reply starts + * (afterwards, it may have no effect), and ignored for the work that may no + * longer mutate the response (e.g. abandoned at its deadline) + */ +export function skip(response: BindenResponse): void { + if (response[kOperation]() !== null) { + 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()` (and `flush()`), + * or the first `write()` or `end()`. binden may ask for it before (see + * `#choose()`): the commit then applies that decision. + */ +export class CompressedResponse { + /** The context, which the `filter` gets (see `#filter()`) */ + readonly #context: Context; + readonly #settings: ISettings; + readonly #response: IResponse; + /** + * What the decision reads of the request, read once as the compression is + * installed: its `Accept-Encoding`, whether it is to `HEAD`, and whether it + * is HTTP/1.0. Never read later from the context (whose getters may fail + * by then) + */ + readonly #accepted: string | undefined; + readonly #head: boolean; + readonly #old: boolean; + // The methods that the compression replaces, as they were + readonly #writeHead: IResponse["writeHead"]; + readonly #write: IResponse["write"]; + readonly #end: IResponse["end"]; + readonly #flush: IResponse["flush"]; + #sink: ISink = "pending"; + /** + * The decision that the representation chosen for the body (see + * `#choose()`) has taken before the headers are committed, which applies + * it: `null` without one + */ + #chosen: IOutcome | null = null; + #compressor: ICompressor | null = null; + /** The listener of the response that destroys the compressor once closed */ + #destroy: (() => void) | null = null; + /** The flush of the coding of the compressor (see `flushKind()`) */ + #kind = 0; + /** Whether the application has ended a compressed response */ + #ended = false; + /** + * The callbacks of the `end()` calls of the application that wait for the + * end of the response (see `owned()`): of a compressed response, which + * the end of the response by the compressor gets (see `#start()`), and of + * the first `end()` of a replaced one, called as the end of the + * replacement is (see `#onReplaced()`). `null` once they have been given, + * but to a destroyed response, which keeps them until it has closed (see + * `#start()`). + */ + #ending: ICallback[] | null = []; + /** + * The callbacks of the compressed writes that the compressor has not + * called back yet (see `pendingOnce()`), which get an error once it is + * destroyed (see `#abandon()`) + */ + readonly #writing = new Set(); + /** + * How Node.js has called back the end of a replacement: its receiver and + * its arguments (see `#replace()`), `null` until it has + */ + #replacement: { + readonly receiver: unknown; + readonly args: unknown[]; + } | null = null; + /** Set while `flush()` writes its marker (see `#onWrite()`) */ + #flushing = false; + /** 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 }[] = []; + /** + * The operation of the work that has committed the headers (see + * `IResponseOperation`): the compressor writes as its owner, and keeps + * writing what it has accepted once the response is ended + */ + #operation: IResponseOperation | null = null; + + private constructor( + context: Context, + response: IResponse, + settings: ISettings, + ) { + const { request } = context; + const { headers, method, httpVersion } = request; + this.#context = context; + this.#settings = settings; + this.#response = response; + this.#accepted = headers["accept-encoding"]; + this.#head = method === "HEAD"; + this.#old = httpVersion === "1.0"; + this.#writeHead = response.writeHead.bind(response); + this.#write = response.write.bind(response); + this.#end = response.end.bind(response); + this.#flush = response.flush.bind(response); + } + + /** + * Installs the compression on the response of a context, unless its headers + * have been sent (it is then left as it is), a compression is installed on + * it already, or the current work may no longer mutate it (e.g. abandoned + * at its deadline, whose compression would change the reply of another) + */ + public static install(context: Context, settings: ISettings): void { + const { response } = context; + if ( + response.headersSent || + installed.has(response) || + response[kOperation]() === null + ) { + return; + } + installed.add(response); + new CompressedResponse(context, response, settings).#hook(); + } + + /** + * Replaces the methods of the response, and chooses the representation of + * its body for binden (see `IRepresentationNegotiator`) + */ + #hook(): void { + const response = this.#response; + response[kNegotiator]({ + choose: (length: number | null): IRepresentation => this.#choose(length), + forget: (): void => { + this.#chosen = null; + }, + }); + // What they throw is formatted (see `formatStack()`), as by the methods + // of binden that they replace (e.g. the error of Node.js for a chunk that + // it refuses): inline, they are hot paths + response.writeHead = (...args: unknown[]): IResponse => { + try { + return this.#onWriteHead(args); + } catch (error: unknown) { + throw formatStack(error as Error); + } + }; + response.write = (...args: unknown[]): boolean => { + try { + return this.#onWrite(args); + } catch (error: unknown) { + throw formatStack(error as Error); + } + }; + response.end = (...args: unknown[]): IResponse => { + try { + return this.#onEnd(args); + } catch (error: unknown) { + throw formatStack(error as Error); + } + }; + 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; + } + }; + // As for an uncompressed response, `writableEnded` is set by `end()` + // (a compressed response is ended once the compressor is), read on the + // response that it is read of (e.g. by binden as it destroys it), never + // through what the context has given (whose traps may fail by then) + const compressed = (): boolean => this.#ended; + Object.defineProperty(response, "writableEnded", { + configurable: true, + get(this: object): boolean { + return ( + compressed() || + (Reflect.get( + Object.getPrototypeOf(this) as object, + "writableEnded", + this, + ) as boolean) + ); + }, + }); + } + + /** + * The operation of the current work (see `kOperation`), whose callbacks + * run as that work (see `owned()`): `null` when it may not mutate the + * response (once the response is sealed, the calls of the work that is + * abandoned are ignored). The operation of the work is kept for the + * compressor until the decision is taken. + */ + #allowed(): IResponseOperation | null { + const operation = this.#response[kOperation](); + if (this.#sink === "pending") { + this.#operation = operation; + } + return operation; + } + + /** + * `writeHead()`: its arguments are checked as Node.js does, the headers it + * is given are set (as Node.js does), then the decision is taken, and the + * headers are committed + */ + #onWriteHead(args: unknown[]): IResponse { + const response = this.#response; + if (this.#allowed() === null) { + return response; + } + if (this.#sink !== "pending" || response.headersSent || this.#gone()) { + // Decided already, or destroyed (no compressor, see `#gone()`): as + // without the compression, which reads nothing of the arguments + return Reflect.apply(this.#writeHead, response, args) as IResponse; + } + // The status of the response itself (e.g. of `flushHeaders()`, whose + // head Node.js commits by it): converted once and stored (see + // `statusOf()`), before the framing is saved and anything else is + // checked, as Node.js stores it before it refuses a reason phrase. A + // status given is converted by the check alone + const [first, ...rest] = args; + const own = first === response.statusCode ? statusOf(response) : first; + this.#commit(() => { + // What Node.js would refuse (e.g. a status code or a reason phrase), + // before anything is changed: no compressor is created. Read once, as + // Node.js gets them (see `kValidateHead`) + const [status, reason, given] = response[kValidateHead](own, ...rest); + setGiven(response, given); + response.statusCode = status; + this.#apply(this.#outcome(null)); + if (this.#sink === "discard") { + return; + } else if (typeof reason === "string") { + this.#writeHead(status, reason); + } else { + this.#writeHead(status); + } + }); + return response; + } + + /** + * Whether the response is destroyed (e.g. the client has gone away while + * the work waited for something else) before the decision: nothing of it + * is sent any more, and no compressor is created for it (once the response + * has closed, nothing would destroy it). It is left as it is: its body goes + * to binden, whose `write()` calls back `ERR_STREAM_DESTROYED`, as without + * a compression. A closed response is destroyed: Node.js, and binden for + * one that it closes itself, set `destroyed` before they emit `close` (see + * also `#start()`). + */ + #gone(): boolean { + return this.#response.destroyed; + } + + /** + * Where the body goes: a response whose headers have been sent by another + * path (e.g. around the compression) before any decision is left as it is + */ + #current(): ISink { + if (this.#sink === "pending" && this.#response.headersSent) { + this.#sink = "as_is"; + } + return this.#sink; + } + + /** + * The encoding of a chunk that the compression decides by, compresses or + * discards, checked as binden checks it (see `encodingOf()`), before + * anything is decided: one that binden refuses throws its error, unless + * Node.js ignores the chunk, as without a compression (see `#writes()` of + * `BindenResponse`): the response is destroyed (also once it has ended + * as it is), or the application has ended it (compressed, or replaced), + * whose status is then neither converted nor stored, or it has no body + * (e.g. to `HEAD`). A replaced response that the application has not ended + * checks it as one that has not ended (see `#onEnd()`). + */ + #encoding(chunk: Uint8Array | string, encoding: unknown): BufferEncoding { + const response = this.#response; + const checked = encodingOf(chunk, encoding); + if ( + checked === null && + !response.destroyed && + !this.#ended && + hasBody(response) + ) { + throw unknownEncoding(encoding); + } + // As Node.js reads one that it ignores + return checked ?? "utf8"; + } + + /** + * `write()`, in the order of binden: the rights of the work, its arguments + * (a chunk that Node.js refuses, `null` or neither a string nor bytes, + * throws the error of Node.js, and an encoding that binden refuses throws + * its error), the chunk as it is written (see `normalized()`), then the + * decision, unless it has been taken. Nothing is decided by a write that + * throws, and the response may still reply. A response sent as it is gets + * its arguments, as without a compression. The callback runs once, as the + * work that has called `write()` (see `owned()`). + */ + #onWrite(args: unknown[]): boolean { + const response = this.#response; + const [chunk, second, third] = args; + const operation = this.#allowed(); + if (operation === null) { + ignore(args); + return true; + } else if (this.#current() === "as_is") { + return Reflect.apply(this.#write, response, normalize(args)) as boolean; + } else if (!isChunk(chunk)) { + return refuse(chunk); + } + const encoding = this.#encoding(chunk, second); + if (this.#sink === "pending") { + const sent = this.#decideForBody( + null, + () => Reflect.apply(this.#write, response, normalize(args)) as boolean, + ); + if (sent !== null) { + return sent.value; + } + } + const callback = (typeof second === "function" ? second : third) as + ICallback | undefined; + if (this.#sink === "discard") { + // The response has been replaced: its writes are discarded, which + // binden is told (a send is not cut for the bytes of a discarded + // write, see `kDiscardWrite`), and their callback runs as for a chunk + // that has been written + response[kDiscardWrite](); + if (callback) { + process.nextTick(callback, null); + } + return true; + } + const compressor = this.#compressor as ICompressor; + 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) { + process.nextTick(callback, writeAfterEnd()); + } + return false; + } + // Decoded by the compressor as `Buffer.from()` decodes it (see + // `normalized()`), and called back by it once, or with an error once it + // is destroyed first (see `#abandon()`) + return callback + ? compressor.write( + chunk, + encoding, + pendingOnce(owned(callback, operation), this.#writing), + ) + : compressor.write(chunk, encoding); + } + + /** + * `end()`, in the order of `write()` (see `#onWrite()`), the decision with + * the size of its chunk as it is written. As for Node.js, a chunk that is + * falsy (e.g. `0`) is none, and a chunk that it refuses throws its error + * before anything is changed, the cork of the socket by `end()` of Node.js + * as well (see `refuse()`): unless the application has ended the response + * as it is, which Node.js answers as without a compression. Its callback + * runs once, as `end()` of Node.js calls it (by the version of Node.js), + * as the work that has called `end()`. + */ + #onEnd(args: unknown[]): IResponse { + const response = this.#response; + const [first, second, third] = args; + const chunk = typeof first !== "function" && Boolean(first) ? first : null; + const operation = this.#allowed(); + if (operation === null) { + ignore(args); + return response; + } + const sink = this.#current(); + if ( + // Also a response destroyed before the decision, which is left as it + // is (see `#gone()`), e.g. once the application has ended it so + (sink === "as_is" || (sink === "pending" && this.#gone())) && + (chunk === null || isChunk(chunk) || response.writableEnded) + ) { + return Reflect.apply(this.#end, response, normalize(args)) as IResponse; + } else if (chunk !== null && !isChunk(chunk)) { + // Before `end()` of Node.js, which corks the socket first (and leaves + // it corked when it throws: the next reply would stall). Also once the + // compression has replaced (and ended) the response, or the + // application has ended it through the compressor, whenever its end + // comes: as for a response that has not ended, rather than the `error` + // event that Node.js emits once it has (which crashes the process + // without a listener) + return refuse(chunk); + } + const encoding = chunk === null ? "utf8" : this.#encoding(chunk, second); + const data = chunk === null ? null : normalized(chunk, encoding); + const callback = [first, second, third].find( + (arg) => typeof arg === "function", + ) as ICallback | undefined; + if (sink === "pending") { + // Without writes before it, its chunk is the body + const sent = this.#decideForBody( + data === null ? 0 : Buffer.byteLength(data, encoding), + () => Reflect.apply(this.#end, response, normalize(args)) as IResponse, + ); + if (sent !== null) { + return sent.value; + } + } + if (this.#sink === "discard") { + // Ended already: its chunk is discarded, which binden is told (see + // `#onWrite()`), and only its callback runs, as for the end of the + // replacement (the first `end()` of the application), or as Node.js + // calls the one of an `end()` that follows it + response[kDiscardWrite](); + if (callback && this.#ended) { + this.#end(callback); + } else if (callback) { + this.#join(owned(callback, operation)); + } + this.#ended = true; + return response; + } else if (!this.#ended) { + // Its chunk and its encoding have been checked: the compressor + // accepts them + (this.#compressor as ICompressor).end(data, encoding); + this.#ended = true; + // What the compressor has accepted is sent, even if the work that + // has ended the response is abandoned afterwards + (this.#operation as IResponseOperation).transfer(); + } + // 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. It is given to the end of the response, once the + // compressor has ended (see `#start()`), unless the response is + // destroyed (Node.js does not call it back). + if (!callback) { + return response; + } else if (this.#ending === null || response.destroyed) { + this.#end(callback); + } else { + this.#ending.push(owned(callback, operation)); + } + return response; + } + + /** + * Gives the callback of the first `end()` of a replaced response the + * receiver and the arguments of the end of the replacement (see + * `#onReplaced()`): once it has been called back (asynchronously, as + * `end()` of Node.js calls it), or when it is + */ + #join(callback: ICallback): void { + const replacement = this.#replacement; + if (replacement === null) { + (this.#ending as ICallback[]).push(callback); + } else { + process.nextTick(() => { + Reflect.apply(callback, replacement.receiver, replacement.args); + }); + } + } + + /** + * Takes the decision for the body that is written (`size` is the size of + * the whole body as it is written when `end()` writes it, `null` + * otherwise), and commits the headers: at once for a compressed body, and + * by `send` (the `write()` or the `end()` of the response, whose result is + * returned) for a body sent as it is, so that a head that Node.js refuses + * then is rolled back as well. A destroyed response is sent as it is, + * without a decision (see `#gone()`). `null` when the body does not go to + * the response as it is. + */ + #decideForBody( + size: number | null, + send: () => T, + ): { readonly value: T } | null { + const response = this.#response; + if (this.#gone()) { + return { value: send() }; + } + // Converted once and stored (see `statusOf()`), before the framing is + // saved and the reason phrase is checked, as Node.js stores it: a + // rollback restores that number, which a retry does not convert again + statusOf(response); + let sent: { readonly value: T } | null = null; + this.#commit(() => { + // What Node.js would refuse (e.g. a reason phrase), before anything is + // changed: no compressor is created. The status is checked as Node.js + // checks it, by the value given (see `kValidateHead`), and decided by + const [status] = response[kValidateHead](response.statusCode); + response.statusCode = status; + this.#apply(this.#outcome(size)); + if (this.#sink === "compressor") { + this.#writeHead(status); + } else if (this.#sink === "as_is") { + sent = { value: send() }; + } + }); + return sent; + } + + /** + * Takes the decision and commits the headers (`commit`). Node.js may still + * refuse them once the decision is taken (e.g. a status code out of range, + * or a `Trailer` of a body that is not chunked), after it has changed how + * it frames the response: nothing of the attempt is then left (see + * `#rollback()`), and the representation chosen for the body, which is not + * sent, is forgotten. The error is replied with a decision of its own, + * rather than followed by compressed bytes. Once the headers are sent + * (e.g. a `Content-Length` that `strictContentLength` refuses once the + * head is committed), the decision stands: the arguments of a write are + * checked before anything is decided (see `#onWrite()`). + */ + #commit(commit: () => void): void { + const response = this.#response; + const state = response[kSaveFraming](); + try { + commit(); + } catch (error: unknown) { + if (!response.headersSent) { + this.#rollback(state); + } + throw error; + } + } + + /** + * Restores the response as it was before an attempt to commit its headers + * (see `#commit()`): the compressor, which has taken no write, is destroyed + * (see `#abandon()`, its listener on the response removed), the decision + * is pending again, and the framing state of the response (its status, its + * headers and the framing fields of Node.js, see `kSaveFraming`) is the + * one saved before. Only these: the listeners of the application and the + * I/O that the attempt has performed (none before its head) are never + * restored. + */ + #rollback(state: IFramingState): void { + const response = this.#response; + const compressor = this.#compressor; + if (compressor !== null) { + response[kUnlisten]("close", this.#destroy as () => void); + this.#abandon(compressor); + this.#compressor = null; + } + this.#chosen = null; + this.#sink = "pending"; + response[kRestoreFraming](state); + } + + /** + * The representation of a body of `length` bytes (`null` when it is not + * known), chosen for binden before the preconditions of the request are + * evaluated (see `IRepresentationNegotiator`): nothing is sent nor changed, + * and no compressor is created. The commit of the headers applies it. + */ + #choose(length: number | null): IRepresentation { + if (this.#response.headersSent) { + // Committed already (by a decision, or around the compression), e.g. + // for a reply that starts afterwards: nothing is decided again (nor + // the `filter` called), the body is sent as the headers have set + return { coding: null, status: null }; + } + const outcome = this.#decide(length); + this.#chosen = outcome; + return { + coding: outcome.kind === "compress" ? outcome.coding : null, + status: outcome.kind === "replace" ? outcome.status : null, + }; + } + + /** + * The decision at the commit of the headers: the one of the representation + * chosen for the body, otherwise the one of the response as it is (`size` + * is the size of the whole body when `end()` writes it, `null` otherwise). + * A response to `HEAD` gets the decision of `GET` by the same body: the one + * that its `end()` writes (which Node.js does not send), or the length that + * a writer that does not write it chooses (e.g. `send()` of a stream, which + * is not read). A `304` gets the decision of the `200` that it stands for: + * its `Content-Length`, when it has one, is the length of that `200` + * (binden keeps it on the `304` of its dynamic `ETag` and of `sendFile()`). + */ + #outcome(size: number | null): IOutcome { + const chosen = this.#chosen; + if (chosen !== null) { + this.#chosen = null; + return chosen; + } + const response = this.#response; + // The body that `end()` writes is not the one of the `200` of a `304` + return this.#decide( + lengthOf(response) ?? (response.statusCode === 304 ? null : size), + ); + } + + /** + * The decision for a body of `length` bytes (`null` when it is not known), + * by the status and the headers of the response, the same for `GET` and + * `HEAD`: it changes nothing (see `#apply()`). The request accepts the + * content codings of `Accept-Encoding` (RFC 9110, section 12.5.3): a + * response that may be compressed gets the acceptable coding that it + * prefers, otherwise the response as it is when it is acceptable, otherwise + * `406`. Except for a response without a body (and a failed `filter`), the + * decision depends on `Accept-Encoding` (`Vary`). + */ + #decide(length: number | null): IOutcome { + const header = this.#accepted; + const response = this.#response; + // As Node.js commits it (see `statusOf()`) + const status = statusOf(response); + if (status < 200 || status === 204 || status === 205 || length === 0) { + // No body: whatever the request accepts + return AS_IS; + } + const encoding = response.getHeader("Content-Encoding"); + if (typeof encoding !== "undefined" && !isIdentity(encoding)) { + // Encoded already (e.g. a precompressed file), and no other version: + // any coding is acceptable without `Accept-Encoding`, none with an + // empty one + const codings = listOf(encoding).filter( + (coding) => coding !== "identity", + ); + return accepts(header, codings) ? AS_IS_VARY : NOT_ACCEPTABLE; + } + const offers = this.#offers(length, status); + if (offers === null) { + return FAILED; + } + // `identity` without `Accept-Encoding` (or with an empty one) + const coding = negotiate(header, offers); + if (coding === false) { + return NOT_ACCEPTABLE; + } + return coding === "identity" + ? AS_IS_VARY + : { kind: "compress", coding, vary: true }; + } + + /** + * The codings that a response of a body of `length` bytes may be + * compressed with (`null` once the `filter` has failed): those of `auto`, + * or the fixed `format`, but none for a range (RFC 9110, section 14.1.2: + * ranges refer to the representation as it is), `no-transform`, + * `skipCompression()`, a type that the `filter` refuses, or a body below + * the `threshold` (`status` is the status of the response, as Node.js + * commits it, see `statusOf()`) + */ + #offers( + length: number | null, + status: number, + ): readonly ICompressEncodings[] | null { + const response = this.#response; + if ( + status === 206 || + response.hasHeader("Content-Range") || + noTransform(response.getHeader("Cache-Control")) || + skipped.has(response) + ) { + return []; + } + const filtered = this.#filter(); + if (filtered === null) { + return null; + } else if ( + !filtered || + (length !== null && length < this.#settings.threshold) + ) { + return []; + } + const { format, encodings } = this.#settings; + return format === "auto" ? encodings : [format]; + } + + /** + * The result of the `filter`: `null` when it fails, or does not return a + * boolean (a configuration error, logged through the guarded path of + * binden, see `logTo()`: a method, a `child()` or a `log` getter that the + * application has replaced, or that throws, never runs nor fails the + * decision, and the response is replaced all the same) + */ + #filter(): boolean | null { + const context = this.#context; + const raw = this.#response.getHeader("Content-Type"); + const type = typeof raw === "string" ? ContentType.fromString(raw) : null; + const outcome = attempt(() => this.#settings.filter(context, type)); + if (!outcome.ok) { + logTo(context, "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); + logTo( + context, + "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; + } + const encoding = response.getHeader("Content-Encoding"); + if (typeof encoding !== "undefined" && isIdentity(encoding)) { + // Not a content coding (RFC 9110, section 8.4.1) + response.removeHeader("Content-Encoding"); + } + if (outcome.vary) { + response.vary("Accept-Encoding"); + } + if (outcome.kind === "compress" && this.#code(outcome.coding)) { + this.#kind = flushKind(outcome.coding); + this.#compressor = this.#start(outcome.coding); + this.#sink = "compressor"; + return; + } + this.#sink = "as_is"; + } + + /** + * Sets the headers of the compressed representation: whether its body + * goes to a compressor. A `304` stands for the compressed `200`, whose + * length is not known, without its body nor its encoding (a cache would + * update the stored response with it); an empty body (e.g. of the `412` + * of a failed precondition of the compressed representation) is not + * compressed either. A response to `HEAD` gets the headers of `GET`. The + * digests of the uncompressed body are removed: they are not those of the + * compressed one (nor of an empty body). + */ + #code(coding: ICompressEncodings): boolean { + const response = this.#response; + const status = response.statusCode; + const empty = status !== 304 && lengthOf(response) === 0; + if (!empty) { + this.#remove("Content-Length"); + } + this.#remove("Accept-Ranges", ...digests); + // 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 (status === 304 || empty) { + return false; + } + response.setHeader("Content-Encoding", coding); + this.#frame(); + return !this.#head; + } + + /** + * Frames the compressed body, whose length is not known (RFC 9112, section + * 6.3), also for `HEAD`: in chunks for HTTP/1.1, and by closing the + * connection for HTTP/1.0, which knows no chunks. Explicitly: Node.js + * would otherwise close the connection of HTTP/1.1 as well once both + * `Content-Length` and `Transfer-Encoding` have been removed (e.g. a stale + * `Transfer-Encoding` by binden), and the next requests of the connection + * would be lost. + */ + #frame(): void { + const response = this.#response; + if (this.#old) { + this.#remove("Transfer-Encoding"); + response.setHeader("Connection", "close"); + } else { + response.setHeader("Transfer-Encoding", "chunked"); + } + } + + /** + * Removes the headers that are set: Node.js also remembers the removal of + * a header that is not, which changes how it frames the body + */ + #remove(...names: string[]): void { + for (const name of names) { + if (this.#response.hasHeader(name)) { + this.#response.removeHeader(name); + } + } + } + + /** + * Replaces the response with an empty one of the status, and sends it: the + * writes of the application are then discarded. The callback of its end + * gets the receiver and the arguments of Node.js, which the callbacks of + * the end of the application get (see `#onReplaced()`). + */ + #replace(status: 406 | 500): void { + const response = this.#response; + this.#remove(...replaced); + if (status === 406) { + // The request has refused every representation + response.vary("Accept-Encoding"); + } + response.setHeader("Content-Length", 0); + this.#sink = "discard"; + this.#writeHead(status, STATUS_CODES[status] as string); + this.#end( + relay((receiver, args) => { + this.#onReplaced(receiver, args); + }), + ); + } + + /** + * The end of a replacement has been called back (see `#replace()`), with + * a receiver and arguments: so are the callbacks of the end of the + * application, the waiting ones now (see `#onEnd()`) + */ + #onReplaced(receiver: unknown, args: unknown[]): void { + const ending = this.#ending as ICallback[]; + this.#replacement = { receiver, args }; + this.#ending = null; + for (const callback of ending) { + Reflect.apply(callback, receiver, args); + } + } + + /** + * 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. It writes as the owner + * of the work that has committed the headers: once that work is abandoned + * (e.g. a timed out error handler), it writes nothing more, unless it had + * ended the response, whose compressed bytes are then all sent. + */ + #start(coding: ICompressEncodings): ICompressor { + const response = this.#response; + const operation = this.#operation as IResponseOperation; + const compressor = createCompressor(coding, this.#settings.compressors); + const ended = (): void => { + // Not cleared yet: the close of the response, which clears them, also + // removes this listener (see `#destroy`) + const ending = this.#ending as ICallback[]; + // The callbacks of `end()` are those of the end of the response (the + // first one), and of the `end()` calls that follow it: Node.js calls + // them as it calls those of an uncompressed response, once each + const handed = new Set(); + const given = ending.map((callback) => pendingOnce(callback, handed)); + this.#ending = null; + const [first, ...after] = given; + operation.run(() => { + this.#end(...(typeof first === "undefined" ? [] : [first])); + for (const callback of after) { + this.#end(callback); + } + }); + if (response.destroyed) { + // Destroyed before its end (or by it), but not closed yet: Node.js + // does not call back the end of a destroyed response, they get the + // error once it has closed (see `#destroy`), unless it has called + // them first + this.#ending = given; + } + }; + compressor + .on("data", (chunk: Buffer) => { + this.#produced += chunk.length; + const accepted = operation.run(() => + this.#write(chunk, (error?: Error | null) => { + if (!error) { + this.#written += chunk.length; + this.#release(); + } + }), + ); + if (!accepted) { + compressor.pause(); + // Of the transfer, not of a work: never called early (see `kListen`) + response[kListen]("drain", () => { + compressor.resume(); + }); + } + }) + .on("drain", () => { + response.emit("drain"); + }) + .on("error", () => { + operation.run(() => response.destroy()); + }) + .once("end", ended); + this.#destroy = (): void => { + // Closed: an end of the compressor that comes afterwards (scheduled + // before it has been destroyed) has nothing more to end, and its + // callbacks are called back here + compressor.off("end", ended); + // Its pending writes fail (and `flush()` rejects by itself) + this.#abandon(compressor); + // Not ended by the compressor: the callbacks of `end()` get the error, + // as Node.js calls those of a response destroyed before it has finished + const ending = this.#ending ?? []; + this.#ending = null; + for (const callback of ending) { + process.nextTick(callback, response.errored ?? destroyed("end")); + } + }; + if (response.closed) { + // Closed already (e.g. its connection has closed while the `filter` + // ran, after `#gone()`): its `close` would never come, the compressor + // is destroyed at once, and the writes that it gets fail + this.#destroy(); + } else { + // The real close only: an abandoned work that sees its response closed + // early (see `kListen`) does not cut what the compressor has accepted + // (removed by `kUnlisten`, see `#rollback()`) + response[kListen]("close", this.#destroy); + } + return compressor; + } + + /** + * Destroys the compressor (once the response has closed, see `#destroy`, + * or by a rollback, before any write): the callbacks of the writes that it + * has not called back get the error of the response (or + * `ERR_STREAM_DESTROYED`) on the next tick, once each, as the work that has + * called `write()` and without a receiver, as Node.js calls those of a + * stream destroyed before it has written them. A destroyed compressor never + * calls the one of the write that it is compressing (nor of those after + * it) once the response is full, and may call it later without an error + * otherwise: such a call is ignored (see `pendingOnce()`). They are + * scheduled before the compressor is destroyed, which would call those + * that it has buffered with an error of its own. + */ + #abandon(compressor: ICompressor): void { + const { errored } = this.#response; + for (const callback of this.#writing) { + process.nextTick(callback, errored ?? destroyed("write")); + } + compressor.destroy(); + } + + /** + * 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/acceptable.test.ts b/packages/compression/test/acceptable.test.ts new file mode 100644 index 00000000..7c3aa774 --- /dev/null +++ b/packages/compression/test/acceptable.test.ts @@ -0,0 +1,502 @@ +/* eslint-disable init-declarations */ +import { Compression, skipCompression } from "../index.js"; +import { type Context, bodyHeaders } from "binden"; +import { + type IReceived, + type IServed, + fetchRaw, + gzips, + none, + serve, + text, +} from "./helpers.js"; +import { after, afterEach, before, suite, test } from "node:test"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import zlib from "node:zlib"; + +/** The status and the headers that tell the representation of a response */ +function representation({ + status, + headers, + raw, +}: IReceived): Record { + return { + status, + encoding: headers["content-encoding"], + vary: headers.vary, + etag: headers.etag, + length: headers["content-length"], + bytes: raw.length, + }; +} + +/** Sends a request with `Accept-Encoding` (none when `null`) */ +function accepting( + url: string, + header: string | null, + { path = "/", method = "GET", headers = {} } = {}, +): Promise { + return fetchRaw(url, { + path, + method, + headers: + header === null ? headers : { ...headers, "Accept-Encoding": header }, + }); +} + +const methods = ["GET", "HEAD"] as const; + +suite("The representation that the request accepts", () => { + let tmp: string; + let empty: string; + let precompressed: string; + const content = text(4096); + const gzipped = zlib.gzipSync(content); + const servers: IServed[] = []; + + const start = async ( + etag: boolean, + handler: (context: Context) => Promise, + ): Promise => { + const served = await serve({ etag }, new Compression(), handler); + servers.push(served); + return served.url; + }; + + before(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), "binden-compression-"))); + empty = join(tmp, "empty.txt"); + precompressed = join(tmp, "file.txt.gz"); + await writeFile(empty, ""); + await writeFile(precompressed, gzipped); + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("a response without a body, whatever the request accepts", async () => { + const url = await start(true, async (context: Context) => { + const { response } = context; + switch (context.url.pathname) { + case "/204": + await response.status(204).send("ignored"); + break; + case "/205": + await response.status(205).send(); + break; + case "/string": + await response.send(""); + break; + case "/buffer": + await response.send(Buffer.alloc(0)); + break; + case "/file": + await response.sendFile(empty); + break; + case "/encoded": + response.setHeader("Content-Encoding", "gzip"); + await response.send(); + break; + default: + await response.send(); + } + }); + for (const path of [ + "/204", + "/205", + "/", + "/string", + "/buffer", + "/file", + "/encoded", + ]) { + for (const header of ["identity;q=0", "*;q=0", "gzip;q=0", ""]) { + for (const method of methods) { + const received = await accepting(url, header, { path, method }); + const status = Number(path.slice(1)) || 200; + deepStrictEqual( + [received.status, received.headers.vary, received.raw.length], + [status, none, 0], + `${method} ${path} ${header}`, + ); + deepStrictEqual( + received.headers["content-encoding"], + path === "/encoded" ? "gzip" : none, + ); + } + } + } + }); + + test("threshold, filter, `skipCompression()`, `no-transform` and ranges: as it is when acceptable, otherwise `406`", async () => { + const url = await start(false, async (context: Context) => { + const { response } = context; + const { pathname } = context.url; + response.setHeader("Content-Type", "text/plain"); + if (pathname === "/small") { + await response.send("small"); + return; + } else if (pathname === "/image") { + response.setHeader("Content-Type", "image/png"); + } else if (pathname === "/skip") { + skipCompression(response); + } else if (pathname === "/no-transform") { + response.setHeader("Cache-Control", "no-transform"); + } else if (pathname === "/range") { + response + .status(206) + .setHeader("Content-Range", `bytes 0-4095/${content.length * 2}`); + } + await response.send(content); + }); + const cases: [string | null, number][] = [ + [null, 200], + ["", 200], + ["gzip", 200], + ["br, identity;q=0.5", 200], + ["identity", 200], + ["identity;q=0", 406], + ["gzip, identity;q=0", 406], + ["*;q=0", 406], + ["*;q=0, gzip", 406], + ]; + for (const path of [ + "/small", + "/image", + "/skip", + "/no-transform", + "/range", + ]) { + for (const [header, status] of cases) { + const [get, head] = await Promise.all( + methods.map(async (method) => + accepting(url, header, { path, method }), + ), + ); + const size = path === "/small" ? 5 : content.length; + const expected = { + status: status === 200 && path === "/range" ? 206 : status, + encoding: none, + vary: "Accept-Encoding", + etag: none, + length: `${status === 200 ? size : 0}`, + bytes: status === 200 ? size : 0, + }; + deepStrictEqual( + representation(get), + expected, + `GET ${path} ${String(header)}`, + ); + deepStrictEqual( + representation(head), + { ...expected, bytes: 0 }, + `HEAD ${path} ${String(header)}`, + ); + } + } + }); + + test("the codings that the request accepts, by their weight (`q=0` refuses one)", async () => { + const url = await start(false, async (context: Context) => { + await context.text(content); + }); + const cases: [string, number, string | undefined][] = [ + ["gzip;q=0", 200, none], + ["gzip;q=0, *", 200, "br"], + ["br;q=0, gzip", 200, "gzip"], + ["*;q=0, deflate", 200, "deflate"], + ["x-gzip;q=0.5, identity;q=0", 200, "gzip"], + ["identity;q=0, *;q=0.5", 200, "br"], + ["br;q=0, gzip;q=0, deflate;q=0", 200, none], + ["br;q=0, gzip;q=0, deflate;q=0, identity;q=0", 406, none], + ["br;q=0, *;q=0", 406, none], + ["identity;q=0", 406, none], + ]; + for (const [header, status, coding] of cases) { + for (const method of methods) { + const received = await accepting(url, header, { method }); + deepStrictEqual( + [ + received.status, + received.headers["content-encoding"], + received.headers.vary, + ], + [status, coding, "Accept-Encoding"], + `${method} ${header}`, + ); + deepStrictEqual( + received.body, + method === "GET" && status === 200 ? content : "", + ); + } + } + }); + + test("an encoded response: as it is when the request accepts its coding (any without the header), otherwise `406`", async () => { + const validator = `"v1"`; + const url = await start(true, async (context: Context) => { + const { response } = context; + response.setHeader("Content-Encoding", "gzip"); + response.setHeader("Content-Type", "text/plain"); + switch (context.url.pathname) { + case "/file": + await response.sendFile(precompressed, { validator }); + break; + case "/stream": + response.setHeader("Content-Length", gzipped.length); + await response.send(Readable.from([gzipped])); + break; + default: + // The dynamic `ETag` + await response.send(gzipped); + } + }); + const tags = new Map(); + const accepted = [null, "gzip", "x-gzip", "*", "br, gzip;q=0.1"]; + const refused = ["", "gzip;q=0", "br", "identity", "*;q=0", "*, gzip;q=0"]; + for (const path of ["/", "/file", "/stream"]) { + for (const method of methods) { + for (const header of accepted) { + const created = await gzips(async () => { + const received = await accepting(url, header, { path, method }); + const { etag } = received.headers; + deepStrictEqual( + representation(received), + { + status: 200, + encoding: "gzip", + vary: "Accept-Encoding", + // Kept strong: the bytes are not changed + etag: path === "/stream" ? none : etag, + length: `${gzipped.length}`, + bytes: method === "GET" ? gzipped.length : 0, + }, + `${method} ${path} ${String(header)}`, + ); + if (method === "GET") { + // Not coded twice + deepStrictEqual(received.body, content); + } + if (typeof etag === "string") { + deepStrictEqual(etag.startsWith("W/"), false); + tags.set(path, etag); + } + }); + deepStrictEqual(created.length, 0); + } + for (const header of refused) { + deepStrictEqual( + representation(await accepting(url, header, { path, method })), + { + status: 406, + encoding: none, + vary: "Accept-Encoding", + etag: none, + length: "0", + bytes: 0, + }, + `${method} ${path} ${header}`, + ); + } + } + } + + // The preconditions of the representation as it is, and its ranges + for (const path of ["/", "/file"]) { + const tag = String(tags.get(path)); + for (const method of methods) { + const status = async ( + header: string, + headers: Record, + ): Promise => { + const received = await accepting(url, header, { + path, + method, + headers, + }); + return [received.status, received.headers["content-encoding"]]; + }; + deepStrictEqual(await status("gzip", { "If-Match": tag }), [ + 200, + "gzip", + ]); + // Empty: without the coding of the body + deepStrictEqual(await status("gzip", { "If-Match": `"other"` }), [ + 412, + none, + ]); + deepStrictEqual(await status("gzip", { "If-None-Match": tag }), [ + 304, + "gzip", + ]); + // `406` first + deepStrictEqual(await status("br", { "If-None-Match": tag }), [ + 406, + none, + ]); + deepStrictEqual(await status("br", { "If-Match": `"other"` }), [ + 406, + none, + ]); + } + } + const partial = await accepting(url, "gzip", { + path: "/file", + headers: { Range: "bytes=0-9", "If-Range": validator }, + }); + deepStrictEqual( + [ + partial.status, + partial.headers["content-encoding"], + partial.headers["content-range"], + ], + [206, "gzip", `bytes 0-9/${gzipped.length}`], + ); + deepStrictEqual(partial.raw, gzipped.subarray(0, 10)); + }); + test("an empty replacement has none of the headers of the body that it replaces, as an error reply of binden", async () => { + const errors: unknown[] = []; + // Valid values of the headers of a body (any other is "x"): not + // encoded, so that the `filter` decides + const values: Record = { + "Content-Encoding": "identity", + "Content-Length": "4", + "Content-Range": "bytes 0-3/8", + "Content-Type": "text/plain", + ETag: '"e"', + "Last-Modified": new Date(0).toUTCString(), + Trailer: "X-T", + "Transfer-Encoding": "chunked", + }; + const served = await serve( + { + app: { + error_handler: (context: Context, error: unknown): void => { + errors.push((error as { code?: unknown }).code); + context.response.destroy(); + }, + }, + }, + new Compression({ + threshold: 0, + filter: (context: Context): boolean => { + if (context.url.pathname === "/failed") { + throw new Error("Failed filter"); + } + return true; + }, + }), + (context: Context) => { + const { response } = context; + context.done = true; + if (context.url.pathname === "/trailer") { + // The reproduction of the review + response.setHeader("Trailer", "X-T"); + } else { + for (const name of [...bodyHeaders, "Accept-Ranges"]) { + // A range is never compressed (nor filtered) + if ( + name !== "Content-Range" || + context.url.pathname !== "/failed" + ) { + response.setHeader(name, values[name] ?? "x"); + } + } + } + response.end("body"); + }, + ); + servers.push(served); + const removed = [...bodyHeaders, "Accept-Ranges"] + .map((name) => name.toLowerCase()) + .filter((name) => name !== "content-length"); + for (const [path, header, status] of [ + ["/trailer", "*;q=0", 406], + ["/", "*;q=0", 406], + ["/failed", "gzip", 500], + ] as const) { + for (const method of methods) { + const received = await accepting(served.url, header, { path, method }); + deepStrictEqual( + [ + received.status, + received.headers["content-length"], + received.raw.length, + received.headers.vary, + removed.filter((name) => name in received.headers), + ], + [status, "0", 0, status === 406 ? "Accept-Encoding" : none, []], + `${method} ${path}`, + ); + } + } + deepStrictEqual(errors, []); + }); + + test("a representation that the request refuses is never sent (the reproductions of the review)", async () => { + const tag = `"v1"`; + const file = join(tmp, "file.txt"); + await writeFile(file, content); + const fixed = new Compression({ format: "gzip" }); + const filtered = new Compression({ filter: (): boolean => false }); + const negotiated = new Compression(); + const served = await serve( + {}, + (context: Context) => { + // The compression of each route + const { pathname } = context.url; + const compression = + pathname === "/fixed" + ? fixed + : pathname === "/filtered" + ? filtered + : negotiated; + compression.run(context); + }, + async (context: Context) => { + const { pathname } = context.url; + await (pathname === "/small" + ? context.text("small") + : pathname === "/file" + ? context.response.sendFile(file, { validator: tag }) + : context.text(content)); + }, + ); + servers.push(served); + const cases: [ + string, + Record, + number, + string | undefined, + ][] = [ + ["/fixed", { "Accept-Encoding": "gzip;q=0, identity" }, 200, none], + ["/small", { "Accept-Encoding": "identity;q=0" }, 406, none], + ["/filtered", { "Accept-Encoding": "identity;q=0" }, 406, none], + [ + "/file", + { "Accept-Encoding": "gzip, identity;q=0", "If-Match": tag }, + 412, + none, + ], + ]; + for (const [path, headers, status, coding] of cases) { + for (const method of methods) { + const received = await fetchRaw(served.url, { path, method, headers }); + deepStrictEqual( + [received.status, received.headers["content-encoding"]], + [status, coding], + `${method} ${path}`, + ); + } + } + }); +}); diff --git a/packages/compression/test/access-log.test.ts b/packages/compression/test/access-log.test.ts new file mode 100644 index 00000000..51045850 --- /dev/null +++ b/packages/compression/test/access-log.test.ts @@ -0,0 +1,100 @@ +import { + type IServed, + capture, + converse, + fetchRaw, + pollUntil, + serve, + text, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { Compression } from "../index.js"; +import type { Context } from "binden"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +const body = text(8192); + +suite("The access log of a compressed response", () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("counts the bytes of the body as sent: compressed, and none of a refused write", async () => { + const captured = capture(); + served = await serve( + { app: { logger: captured.logger, access_log: true } }, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + // Which record of the access log is of this response + response.type("text/plain").setHeader("X-Id", context.id); + const { pathname } = context.url; + if (pathname === "/stream") { + await response.send( + Readable.from([body.slice(0, 4096), body.slice(4096)]), + ); + } else if (pathname === "/flushed") { + response.write(body.slice(0, 4096)); + await response.flush(); + response.end(body.slice(4096)); + } else if (pathname === "/late") { + response.end(body); + // Refused (after its end): never written + response.write("late"); + } else { + await response.send(body); + } + }, + ); + const { url } = served; + // The bytes of the body received, by the id of the request + const sent = new Map(); + for (const [path, method, accept] of [ + ["/", "GET", "gzip"], + ["/stream", "GET", "gzip"], + ["/flushed", "GET", "gzip"], + ["/late", "GET", "gzip"], + ["/", "HEAD", "gzip"], + ["/", "GET", "identity;q=0"], + ["/", "GET", "identity"], + ]) { + const received = await fetchRaw(url, { + path, + method, + headers: { "Accept-Encoding": accept }, + }); + sent.set(received.headers["x-id"], received.raw.length); + } + // HTTP/1.0: delimited by the close of the connection + const [response] = await converse( + url, + "GET /stream HTTP/1.0\r\nHost: a\r\nAccept-Encoding: gzip\r\n\r\n", + ); + sent.set( + /\r\nx-id: (?\S+)/u.exec(response.head)?.groups?.id, + response.body.length, + ); + const completed = (): Map => + new Map( + captured.entries + .filter(({ msg }) => msg === "The request has completed") + .map(({ id, body_bytes: bytes }) => [id, bytes]), + ); + // Logged once each response is closed + await pollUntil(() => completed().size >= sent.size); + + deepStrictEqual(completed(), sent); + // Compressed, but for `HEAD`, `406` and `identity` + const bytes = [...sent.values()]; + deepStrictEqual( + bytes.map((count) => count > 0 && count < body.length), + [true, true, true, true, false, false, false, true], + ); + deepStrictEqual(bytes.slice(4, 7), [0, 0, body.length]); + }); +}); diff --git a/packages/compression/test/body.test.ts b/packages/compression/test/body.test.ts new file mode 100644 index 00000000..089bbe4c --- /dev/null +++ b/packages/compression/test/body.test.ts @@ -0,0 +1,103 @@ +import { type Context, type IMiddleware } from "binden"; +import { + type IServed, + dechunk, + exchange, + fetchRaw, + serve, + within, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { Compression } from "../index.js"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +suite("Compression of the bodies of send()", () => { + const servers: IServed[] = []; + + const start = async (...middlewares: IMiddleware[]): Promise => { + const served = await serve({}, ...middlewares); + servers.push(served); + return served; + }; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("a stream shorter or longer than its length cuts a compressed response", async () => { + for (const chunks of [ + ["x".repeat(2000)], + ["x".repeat(3000), "y".repeat(3000)], + ]) { + const { promise: failed, resolve } = Promise.withResolvers(); + const { url } = await start( + new Compression({ format: "gzip" }), + async (context: Context) => { + context.response.type("text/plain").setHeader("Content-Length", 5000); + await context.response.send(Readable.from(chunks)).catch(resolve); + }, + ); + const { body } = await exchange(url); + // Never a complete (chunked) body + deepStrictEqual(dechunk(body).complete, false); + deepStrictEqual( + ((await within(failed)) as { code?: unknown }).code, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ); + } + }); + + test("a chunk written in an encoding is compressed as such", async () => { + const { url } = await start( + new Compression({ format: "gzip" }), + (context: Context) => { + context.response.type("text/plain"); + context.response.write("aGVsbG8=", "base64"); + context.response.end(); + context.done = true; + }, + ); + const received = await fetchRaw(url, { + headers: { "Accept-Encoding": "gzip" }, + }); + deepStrictEqual( + [received.headers["content-encoding"], received.body], + ["gzip", "hello"], + ); + }); + + test("`GET` and `HEAD` of an empty body get the same headers", async () => { + const handlers: [string, (context: Context) => Promise][] = [ + ["send()", async (c): Promise => c.response.send()], + ['send("")', async (c): Promise => c.response.send("")], + [ + "404 with a type", + async (c): Promise => + c.response.status(404).type("text/html").send(), + ], + ["304", async (c): Promise => c.response.status(304).send("x")], + ["204", async (c): Promise => c.response.status(204).send("x")], + ]; + const names = [ + "content-encoding", + "content-length", + "content-type", + "transfer-encoding", + "vary", + ]; + for (const [name, handler] of handlers) { + const { url } = await start(new Compression(), handler); + const [get, head] = await Promise.all( + ["GET", "HEAD"].map(async (method) => + fetchRaw(url, { method, headers: { "Accept-Encoding": "gzip, br" } }), + ), + ); + deepStrictEqual( + names.map((header) => head.headers[header]), + names.map((header) => get.headers[header]), + name, + ); + } + }); +}); diff --git a/packages/compression/test/callbacks.test.ts b/packages/compression/test/callbacks.test.ts new file mode 100644 index 00000000..eadfe30c --- /dev/null +++ b/packages/compression/test/callbacks.test.ts @@ -0,0 +1,896 @@ +import { type Context, type IMiddleware } from "binden"; +import { + type IServed, + compressors, + fetchRaw, + gzips, + pollUntil, + serve, + sleep, + text, + within, +} from "./helpers.js"; +import { type Socket, connect } from "node:net"; +import { afterEach, suite, test } from "node:test"; +import { Compression } from "../index.js"; +import { deepStrictEqual } from "node:assert/strict"; +import { once } from "node:events"; +// The owners of the works (internal to binden), which the callbacks keep +import { owners } from "../../binden/dist/src/seal.js"; +import { randomBytes } from "node:crypto"; + +const gzip = { "Accept-Encoding": "gzip" }; + +/** A callback as it has been called: its name, owner, receiver and arguments */ +type ICall = [string, string, string, unknown[]]; + +/** What a callback has been called with, as the tests compare it */ +function describe(value: unknown): unknown { + return value instanceof Error + ? (value as { code?: unknown }).code + : value === null + ? null + : typeof value; +} + +/** + * Records the calls of the callbacks of a work: by the owner of the work + * that calls them (`stack`, or `none` for a work of no request), and by + * their receiver (`response`, the list of the end callbacks of Node.js 26, + * `none`, or the buffered write of the socket that Node.js 24 calls the + * callback of a write with) + */ +function recorder( + calls: ICall[], + response: object, +): (name: string) => () => void { + const stack = owners.getStore(); + return (name: string) => + function recorded(this: unknown, ...args: unknown[]): void { + const owner = owners.getStore(); + calls.push([ + name, + owner === stack ? "stack" : typeof owner === "undefined" ? "none" : "?", + this === response + ? "response" + : Array.isArray(this) + ? "list" + : typeof this === "undefined" + ? "none" + : "request", + args.map(describe), + ]); + }; +} + +/** Resolves once `count` callbacks have been called (rejects after 10 s) */ +async function calledBack( + calls: readonly ICall[], + count: number, +): Promise { + await pollUntil(() => calls.length >= count).catch(() => { + throw new Error(`${calls.length} of ${count} callbacks called`); + }); +} + +/** The calls in the order of their names (their order differs by path) */ +function sorted(calls: readonly ICall[]): ICall[] { + return calls.toSorted(([a], [b]) => a.localeCompare(b)); +} + +/** + * The replies of the tests: how they write their body, with callbacks of + * the work and of works of no request (`owners.exit()`), which may write + * before the response is sealed + */ +const replies: Record< + string, + ( + response: Context["response"], + record: (name: string) => () => void, + ) => Promise | void +> = { + "write() decides": (response, record) => { + response.write(text(64), record("write")); + owners.exit(() => response.write(text(64), "utf8", record("write none"))); + response.end(text(64), record("end")); + owners.exit(() => response.end(record("end none"))); + }, + "end() decides": (response, record) => { + response.end(text(64), "utf8", record("end")); + owners.exit(() => response.end(record("end none"))); + }, + "writeHead() decides": (response, record) => { + response.writeHead(200); + owners.exit(() => response.write(text(64), record("write none"))); + response.end(record("end")); + }, + "end() once the body has been sent": async (response, record) => { + response.write(text(64), record("write")); + await response.flush(); + await sleep(20); + response.end(text(64), record("end")); + }, +}; + +/** The calls that each reply makes */ +const counts: Record = { + "write() decides": 4, + "end() decides": 2, + "writeHead() decides": 2, + "end() once the body has been sent": 2, +}; + +suite("The callbacks of `write()` and `end()`", () => { + const servers: IServed[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + /** The calls of the callbacks of a reply, with or without the compression */ + const callsOf = async ( + reply: string, + compression: boolean, + accept: string, + ): Promise<{ status: number; calls: ICall[] }> => { + const calls: ICall[] = []; + const middlewares: IMiddleware[] = compression + ? [new Compression({ format: "gzip", threshold: 0 })] + : []; + const served = await serve({}, ...middlewares, async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + await replies[reply](response, recorder(calls, response)); + }); + servers.push(served); + const { status } = await fetchRaw(served.url, { + headers: { "Accept-Encoding": accept }, + }); + await calledBack(calls, counts[reply]); + // None runs twice + await sleep(30); + return { status, calls: sorted(calls) }; + }; + + test("run once, as the work that has called them, with the receiver and the arguments of Node.js", async () => { + for (const reply of Object.keys(replies)) { + const plain = await callsOf(reply, false, "gzip"); + // As binden calls them: by their owner, with the receivers of the + // version of Node.js + deepStrictEqual( + plain.calls.map(([name, owner]) => [name, owner]), + plain.calls.map(([name]) => [ + name, + name.endsWith(" none") ? "none" : "stack", + ]), + reply, + ); + // The compressor (and the replacement) calls the callback of a write + // as the callback of a stream: Node.js 24 calls those of the socket + // with the write that it has buffered + const written = plain.calls.map( + ([name, owner, receiver, args]): ICall => [ + name, + owner, + name.startsWith("write") ? "none" : receiver, + args, + ], + ); + for (const [accept, status, expected] of [ + ["identity", 200, plain.calls], + ["gzip", 200, written], + ["identity;q=0", 406, written], + ] as const) { + deepStrictEqual( + await callsOf(reply, true, accept), + { status, calls: expected }, + `${reply}: ${accept}`, + ); + } + } + }); + + test("of `end()` run once with an error when the compressed response is destroyed before its end", async () => { + for (const reason of [null, "ECONNRESET"]) { + const calls: ICall[] = []; + const states: unknown[] = []; + const served = await serve( + {}, + new Compression({ format: "gzip", threshold: 0 }), + (context: Context) => { + const { response } = context; + context.done = true; + const record = recorder(calls, response); + response.type("text/plain"); + response.write(text(64)); + response.end(text(64), record("end")); + owners.exit(() => response.end(record("end none"))); + // Before the compressor has ended (e.g. the client has gone away) + response.destroy( + ...(reason === null + ? [] + : [Object.assign(new Error("gone"), { code: reason })]), + ); + states.push(response.writableEnded, response.writableFinished); + }, + ); + servers.push(served); + await fetchRaw(served.url, { headers: gzip }).catch(() => null); + await calledBack(calls, 2); + await sleep(30); + + // The error of the response, if any + const code = reason ?? "ERR_STREAM_DESTROYED"; + deepStrictEqual(states, [true, false]); + deepStrictEqual(sorted(calls), [ + ["end", "stack", "none", [code]], + ["end none", "none", "none", [code]], + ]); + } + }); + + test("of an `end()` after a destroyed compressed response are left to Node.js", async () => { + const calls: ICall[] = []; + const { promise: closed, resolve } = Promise.withResolvers(); + const served = await serve( + {}, + new Compression({ format: "gzip", threshold: 0 }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + response.write(text(64)); + response.once("close", () => { + response.end(text(64), recorder(calls, response)("end")); + resolve(null); + }); + response.destroy(); + }, + ); + servers.push(served); + await fetchRaw(served.url, { headers: gzip }).catch(() => null); + await within(closed); + await sleep(50); + + // As for an `end()` of a response that has been destroyed + deepStrictEqual(calls, []); + }); +}); + +/** The codings of the compressors */ +const codings = ["gzip", "br", "deflate", "zstd"] as const; + +/** + * The options of a test whose response is backed up by a client that reads + * nothing: Windows takes a whole write while its send buffer has room, so + * that the compressor may never wait for the response + */ +const backedUpBySlowClient = { + skip: process.platform === "win32", + timeout: 60_000, +}; + +/** + * Sends a request that accepts `coding` (after `before`, e.g. a request that + * the response waits for) on a new connection that is never read (a slow + * client): the connection, which the test closes + */ +async function slowClient( + url: string, + coding: string, + before = "", +): Promise { + const socket = connect(Number(new URL(url).port), "127.0.0.1"); + socket.on("error", () => null); + await within(once(socket, "connect")); + socket.pause(); + socket.write( + `${before}GET /next HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: ${coding}\r\n\r\n`, + ); + return socket; +} + +/** + * Resolves once the writes of a response wait for its `drain`: the client + * reads nothing, and the compressor is paused (rejects after 10 s) + */ +async function full(response: Context["response"]): Promise { + await pollUntil(() => response.writableNeedDrain).catch(() => { + throw new Error("The response has not been backed up"); + }); +} + +/** + * A recorded callback (see `recorder()`) that also settles a promise: it + * resolves, or rejects with the error that it is called with + */ +function settling( + recorded: () => void, + resolve: () => void, + reject: (error: unknown) => void, +): (error?: Error | null) => void { + return function settled(this: unknown, ...args: unknown[]): void { + Reflect.apply(recorded, this, args); + const [error] = args as [Error | null | undefined]; + if (error) { + reject(error); + } else { + resolve(); + } + }; +} + +/** The code of an error (as the tests compare it) */ +function codeOf(error: unknown): unknown { + return (error as { code?: unknown }).code; +} + +suite("The callbacks of a compressed response that is destroyed", () => { + const servers: IServed[] = []; + const sockets: Socket[] = []; + + afterEach(async () => { + for (const socket of sockets.splice(0)) { + socket.destroy(); + } + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test( + "of a large write that the middleware awaits run once with the error when the client resets", + backedUpBySlowClient, + async () => { + for (const coding of codings) { + const calls: ICall[] = []; + const steps: unknown[] = []; + const { promise: backedUp, resolve: backed } = + Promise.withResolvers(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const served = await serve( + {}, + new Compression({ format: coding, threshold: 0 }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const written = recorder(calls, response)("write"); + try { + // Incompressible: it waits in the compressor, which the + // response has paused + await new Promise((resolve, reject) => { + response.write( + randomBytes(8 * 1024 * 1024), + settling(written, resolve, reject), + ); + backed(full(response).then(() => null)); + }); + steps.push("written"); + } catch (error: unknown) { + steps.push(codeOf(error)); + } finally { + steps.push("finally"); + settle(null); + } + }, + ); + servers.push(served); + const socket = await slowClient(served.url, coding); + sockets.push(socket); + await within(backedUp); + socket.resetAndDestroy(); + await within(settled); + // None runs twice + await sleep(50); + + deepStrictEqual( + { coding, calls, steps }, + { + coding, + calls: [["write", "stack", "none", ["ERR_STREAM_DESTROYED"]]], + steps: ["ERR_STREAM_DESTROYED", "finally"], + }, + ); + } + }, + ); + + test( + "of many writes and of the end run once each, in order, when the client resets", + backedUpBySlowClient, + async () => { + for (const coding of codings) { + const calls: ICall[] = []; + let finallies = 0; + const { promise: backedUp, resolve: backed } = + Promise.withResolvers(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const served = await serve( + {}, + new Compression({ format: coding, threshold: 0 }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const record = recorder(calls, response); + try { + for (let index = 0; index < 256; index += 1) { + response.write(randomBytes(64 * 1024), record(`${index}`)); + } + response.end(record("end")); + backed(full(response).then(() => null)); + await once(response, "close"); + } finally { + finallies += 1; + settle(null); + } + }, + ); + servers.push(served); + const socket = await slowClient(served.url, coding); + sockets.push(socket); + await within(backedUp); + socket.resetAndDestroy(); + await within(settled); + await calledBack(calls, 257); + await sleep(50); + + // The writes that the compressor has taken, then the others and the + // end with the error + const failed = calls.findIndex(([, , , [error]]) => error !== null); + deepStrictEqual( + { coding, finallies, calls }, + { + coding, + finallies: 1, + calls: [...Array.from(Array(256).keys(), String), "end"].map( + (name, index): ICall => [ + name, + "stack", + "none", + [index < failed ? null : "ERR_STREAM_DESTROYED"], + ], + ), + }, + ); + // Some writes are still in the compressor + deepStrictEqual(failed < 256, true); + } + }, + ); + + test( + "of the writes that the middleware awaits run once with the error when its deadline cuts a slow client", + backedUpBySlowClient, + async () => { + for (const coding of codings) { + const calls: ICall[] = []; + const steps: unknown[] = []; + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const served = await serve( + {}, + new Compression({ format: coding, threshold: 0 }), + async (context: Context) => { + const { response } = context; + response.type("text/plain"); + const record = recorder(calls, response); + // Its deadline passes only once the client, which reads nothing, + // has backed the response up: the compressor is paused until the + // response drains, so that the write that waits then is never + // called back before. A fixed deadline could pass while the + // compressor still compresses a write (e.g. slowly, on a loaded + // machine), which then succeeds, and the writes after it are + // ignored. A drain of the response removes it until the response + // is backed up again + let armed = false; + response.on("drain", () => { + if (armed && !response.writableNeedDrain) { + armed = false; + context.setTimeout(0); + } + }); + const watch = setInterval(() => { + if (!armed && response.writableNeedDrain) { + armed = true; + context.setTimeout(20); + } + }, 5).unref(); + try { + for (let index = 0; index < 64; index += 1) { + const written = record(`${index}`); + await new Promise((resolve, reject) => { + response.write( + randomBytes(256 * 1024), + settling(written, resolve, reject), + ); + }); + } + steps.push("written"); + } catch (error: unknown) { + steps.push(codeOf(error)); + } finally { + clearInterval(watch); + steps.push("finally"); + settle(null); + } + }, + ); + servers.push(served); + sockets.push(await slowClient(served.url, coding)); + await within(settled); + await sleep(50); + + // Each one once the one before has been called back: the last one + // with the error + deepStrictEqual( + { coding, calls, steps }, + { + coding, + calls: calls.map(([name], index): ICall => [ + name, + "stack", + "none", + [index < calls.length - 1 ? null : "ERR_STREAM_DESTROYED"], + ]), + steps: ["ERR_STREAM_DESTROYED", "finally"], + }, + ); + } + }, + ); + + test("of a write that the compressor is compressing run once with the error of the response", async () => { + for (const coding of codings) { + for (const reason of [null, "ECONNRESET"]) { + const calls: ICall[] = []; + const served = await serve( + {}, + new Compression({ format: coding, threshold: 0 }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + response.write( + randomBytes(1024 * 1024), + recorder(calls, response)("write"), + ); + // While the compressor compresses it, without backpressure: it + // calls it back once it has (without an error) afterwards + response.destroy( + ...(reason === null + ? [] + : [Object.assign(new Error("gone"), { code: reason })]), + ); + }, + ); + servers.push(served); + await fetchRaw(served.url, { + headers: { "Accept-Encoding": coding }, + }).catch(() => null); + await calledBack(calls, 1); + await sleep(100); + + deepStrictEqual( + { coding, calls }, + { + coding, + calls: [ + ["write", "stack", "none", [reason ?? "ERR_STREAM_DESTROYED"]], + ], + }, + ); + } + } + }); + + test("of `end()` run once when the response is destroyed as the compressor ends", async () => { + // A response that waits for the one before it (no socket yet) closes on + // the next tick (before the end of the compressor, since Node.js 24.16), + // another one once its socket has closed (after the end) + const first = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + for (const before of ["", first]) { + const calls: ICall[] = []; + const { promise: closed, resolve } = Promise.withResolvers(); + await gzips(async (created) => { + const served = await serve( + {}, + new Compression({ format: "gzip", threshold: 0 }), + async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/") { + await sleep(200); + response.end(); + return; + } + context.done = true; + response.type("text/plain"); + response.write(text(64)); + // Compressed + await sleep(20); + // The last compressed bytes: the compressor emits its end on the + // next tick + created.at(-1)?.once("data", () => { + response.destroy(); + }); + response.end(recorder(calls, response)("end")); + await once(response, "close"); + resolve(null); + }, + ); + servers.push(served); + sockets.push(await slowClient(served.url, "gzip", before)); + await within(closed); + await calledBack(calls, 1); + await sleep(50); + }); + + deepStrictEqual( + { before, calls }, + { + before, + calls: [["end", "stack", "none", ["ERR_STREAM_DESTROYED"]]], + }, + ); + } + }); + + test("of `end()` run once when the end of the response destroys it", async () => { + for (const order of ["destroyed", "ended"]) { + const calls: ICall[] = []; + const { promise: closed, resolve } = Promise.withResolvers(); + const served = await serve( + {}, + (context: Context) => { + // E.g. an instrumentation of `end()`, under the compression: the + // response is destroyed by the end of the compressed response, + // before (or once) Node.js takes its callbacks + const { response } = context; + const end = response.end.bind(response) as ( + ...args: unknown[] + ) => Context["response"]; + response.end = (...args: unknown[]): Context["response"] => { + if (order === "destroyed") { + response.destroy(); + return end(...args); + } + const ended = end(...args); + response.destroy(); + return ended; + }; + }, + new Compression({ format: "gzip", threshold: 0 }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + response.end(text(64), recorder(calls, response)("end")); + await once(response, "close"); + resolve(null); + }, + ); + servers.push(served); + await fetchRaw(served.url, { headers: gzip }).catch(() => null); + await within(closed); + await calledBack(calls, 1); + await sleep(50); + + // Destroyed before: with the error once it has closed. Afterwards: as + // Node.js calls it (by the version of Node.js), and not again + deepStrictEqual( + { order, calls: calls.map(([name, owner]) => [name, owner]) }, + { order, calls: [["end", "stack"]] }, + ); + if (order === "destroyed") { + deepStrictEqual(calls, [ + ["end", "stack", "none", ["ERR_STREAM_DESTROYED"]], + ]); + } + } + }); +}); + +/** The codings, and `null` without a compression */ +const modes = [null, ...codings] as const; + +/** The sizes of the writes: within the buffers, and far beyond them */ +const sizes = [100, 8 * 1024 * 1024] as const; + +/** + * How the response of a middleware is gone before its first write: its + * client goes away while the middleware waits for something else (e.g. a + * query), the middleware destroys it, or its connection closes while the + * `filter` of the compression decides (its `close` emitted at once) + */ +type IGone = "client" | "destroy" | "filter"; + +/** The options of `writeGone()` */ +interface IGoneOptions { + /** The coding of the compression, `null` without one */ + readonly coding: (typeof modes)[number]; + readonly size: number; + readonly gone: IGone; + /** What the middleware does before its write (nothing by default) */ + readonly reply?: + ((response: Context["response"], chunk: Buffer) => void) | undefined; +} + +suite("The callbacks of a response that is gone before the decision", () => { + const servers: IServed[] = []; + const sockets: Socket[] = []; + + afterEach(async () => { + for (const socket of sockets.splice(0)) { + socket.destroy(); + } + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + /** + * How a middleware writes once its response is gone: the calls of the + * callback of the write that it awaits, its steps (the error of that + * write, then its `finally`), and whether each compressor created is + * destroyed + */ + const writeGone = async ({ + coding, + size, + gone, + reply = (): null => null, + }: IGoneOptions): Promise<{ + calls: ICall[]; + steps: unknown[]; + created: boolean[]; + }> => { + const calls: ICall[] = []; + const steps: unknown[] = []; + const { promise: started, resolve: start } = Promise.withResolvers(); + const { promise: settled, resolve: settle } = Promise.withResolvers(); + const filter = (context: Context): boolean => { + if (gone === "filter") { + context.request.socket.emit("close", false); + } + return true; + }; + const middlewares = + coding === null + ? [] + : [new Compression({ format: coding, threshold: 0, filter })]; + const created = await compressors(async () => { + const served = await serve( + {}, + ...middlewares, + async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const written = recorder(calls, response)("write"); + try { + start(null); + if (gone === "client") { + // Listened to before the client goes away (see `started`) + await once(response, "close"); + } else if (gone === "destroy") { + response.destroy(); + } + await new Promise((resolve, reject) => { + reply(response, randomBytes(size)); + response.write( + randomBytes(size), + settling(written, resolve, reject), + ); + }); + steps.push("written"); + } catch (error: unknown) { + steps.push(codeOf(error)); + } finally { + steps.push("finally"); + settle(null); + } + }, + ); + servers.push(served); + const socket = await slowClient(served.url, coding ?? "identity"); + sockets.push(socket); + await within(started); + if (gone === "client") { + socket.destroy(); + } + await within(settled); + // None runs twice + await sleep(30); + }); + return { + calls, + steps, + created: created.map((compressor) => compressor.destroyed), + }; + }; + + /** What a write gets, as without a compression */ + const failed = ( + error: string, + ): Omit>, "created"> => ({ + calls: [["write", "stack", "none", [error]]], + steps: [error, "finally"], + }); + + test("of a write that the middleware awaits once its client has gone away run once with the error, as without a compression, without a compressor", async () => { + for (const coding of modes) { + for (const size of sizes) { + deepStrictEqual( + { + coding, + size, + ...(await writeGone({ coding, size, gone: "client" })), + }, + { coding, size, ...failed("ERR_STREAM_DESTROYED"), created: [] }, + ); + } + } + }); + + test("of a write that the middleware awaits once it has destroyed the response (not closed yet) run once with the error, without a compressor", async () => { + for (const coding of modes) { + for (const size of sizes) { + deepStrictEqual( + { + coding, + size, + ...(await writeGone({ coding, size, gone: "destroy" })), + }, + { coding, size, ...failed("ERR_STREAM_DESTROYED"), created: [] }, + ); + } + } + }); + + test("of a write after `writeHead()`, `flushHeaders()` or `end()` once the client has gone away run once with the error, without a compressor", async () => { + const commits: Record< + string, + (response: Context["response"], chunk: Buffer) => void + > = { + writeHead: (response) => response.writeHead(200), + flushHeaders: (response) => { + response.flushHeaders(); + }, + // Node.js never calls back the `end()` of a destroyed response + end: (response, chunk) => response.end(chunk), + }; + for (const coding of modes) { + for (const [name, reply] of Object.entries(commits)) { + // A write after the end of a response gets its own error + const error = + name === "end" + ? "ERR_STREAM_WRITE_AFTER_END" + : "ERR_STREAM_DESTROYED"; + deepStrictEqual( + { + coding, + name, + ...(await writeGone({ coding, size: 100, gone: "client", reply })), + }, + { coding, name, ...failed(error), created: [] }, + ); + } + } + }); + + test("of a write whose compressor starts once the connection has closed run once with the error, the compressor destroyed at once", async () => { + for (const coding of codings) { + for (const size of sizes) { + deepStrictEqual( + { + coding, + size, + ...(await writeGone({ coding, size, gone: "filter" })), + }, + { coding, size, ...failed("ERR_STREAM_DESTROYED"), created: [true] }, + ); + } + } + }); +}); diff --git a/packages/compression/test/chunks.test.ts b/packages/compression/test/chunks.test.ts new file mode 100644 index 00000000..6ee40ec9 --- /dev/null +++ b/packages/compression/test/chunks.test.ts @@ -0,0 +1,448 @@ +import { type BindenResponse, type Context, type IMiddleware } from "binden"; +import { + type IServeOptions, + type IServed, + fetchRaw, + gzips, + none, + serve, + text, + within, +} from "./helpers.js"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, suite, test } from "node:test"; +import { Compression } from "../index.js"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; +import { once } from "node:events"; + +const gzip = { "Accept-Encoding": "gzip" }; +const body = text(4096); + +/** The code and the message of the error that a native response throws, and its state then */ +function refusal( + response: BindenResponse | ServerResponse, + fn: () => unknown, +): unknown[] { + try { + fn(); + } catch (error: unknown) { + const { code, message } = error as { code?: unknown; message?: unknown }; + return [ + code, + message, + response.headersSent, + response.writableEnded, + response.getHeader("Vary") ?? null, + ]; + } + return ["not thrown"]; +} + +/** The refusal of a native response of Node.js (see `refusal()`) */ +function native(fn: (response: ServerResponse) => unknown): unknown[] { + const socket = new Socket(); + const response = new ServerResponse(new IncomingMessage(socket)); + try { + return refusal(response, () => fn(response)); + } finally { + socket.destroy(); + } +} + +/** The chunks that `write()` of Node.js refuses before it changes anything */ +const refused: [string, unknown][] = [ + ["an object", {}], + ["null", null], + ["a number", 42], + ["a Uint16Array", new Uint16Array(4)], +]; + +suite("A chunk that Node.js refuses", () => { + const servers: IServed[] = []; + + /** Serves the middlewares, and returns the URL */ + const start = async ( + options: IServeOptions, + ...middlewares: IMiddleware[] + ): Promise => { + const served = await serve(options, ...middlewares); + servers.push(served); + return served.url; + }; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("by the first `write()` is its error, before any decision", async () => { + for (const [name, chunk] of refused) { + const expected = native((response) => response.write(chunk)); + for (const caught of [false, true]) { + const states: unknown[] = []; + // Created before: it checks its options with a compressor + const compression = new Compression({ format: "gzip" }); + const created = await gzips(async () => { + const url = await start( + { + app: { + error_handler: async (context: Context, error: unknown) => { + const { code, message } = error as Record; + states.push(code, message, context.response.headersSent); + // Its own decision + await context.response.status(500).send(text(2048)); + }, + }, + }, + compression, + (context: Context) => { + const { response } = context; + response.type("text/plain"); + if (!caught) { + response.write(chunk); + } + states.push(refusal(response, () => response.write(chunk))); + response.end(body); + context.done = true; + }, + ); + const received = await fetchRaw(url, { headers: gzip }); + states.push( + received.status, + received.headers["content-encoding"], + received.body, + ); + }); + const label = `${name}${caught ? ", caught" : ""}`; + deepStrictEqual( + [...states, created.length], + caught + ? [expected, 200, "gzip", body, 1] + : [expected[0], expected[1], false, 500, "gzip", text(2048), 1], + label, + ); + } + } + }); + + test("by any later `write()` is its error, as for an uncompressed response", async () => { + const states: unknown[] = []; + const url = await start( + {}, + new Compression({ format: "gzip" }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const ended = context.url.pathname === "/406"; + if (ended) { + // Replaced (with `406`), and ended + response.end(body); + } else { + response.write(body); + } + for (const [, chunk] of refused) { + states.push(refusal(response, () => response.write(chunk))); + } + if (!ended) { + response.end(" end"); + } + }, + ); + const compressed = await fetchRaw(url, { headers: gzip }); + const replaced = await fetchRaw(url, { + path: "/406", + headers: { "Accept-Encoding": "identity;q=0, gzip;q=0" }, + }); + + deepStrictEqual( + [compressed.status, compressed.body, replaced.status, replaced.body], + [200, `${body} end`, 406, ""], + ); + const errors = refused.map(([, chunk]) => + native((response) => response.write(chunk)).slice(0, 2), + ); + deepStrictEqual( + states.map((state) => (state as unknown[]).slice(0, 2)), + [...errors, ...errors], + ); + }); + + test("once the compression has ended the response is its error, as for a response that has not ended", async () => { + const calls: [ + string, + (response: BindenResponse | ServerResponse) => unknown, + ][] = [ + ["end(42)", (response): unknown => response.end(42)], + ["end({})", (response): unknown => response.end({})], + ["write({})", (response): unknown => response.write({})], + ]; + // How the compression ends it: replaced with `406` (by a write, or by + // `writeHead()`), or compressed, and ended by the end of its compressor + // (also once the response has finished) + const starts: [string, string, (response: BindenResponse) => unknown][] = [ + [ + "406 by write()", + "identity;q=0", + (response): unknown => response.write(body), + ], + [ + "406 by writeHead()", + "identity;q=0", + (response): unknown => response.writeHead(200), + ], + ["compressed", "gzip", (response): unknown => response.end(body)], + [ + "compressed, finished", + "gzip", + async (response): Promise => { + response.end(body); + await once(response, "finish"); + }, + ], + ]; + for (const [started, accept, begin] of starts) { + for (const [name, call] of calls) { + const { promise: called, resolve } = Promise.withResolvers(); + const states: unknown[] = []; + const url = await start( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + await begin(response); + states.push(refusal(response, () => call(response)).slice(0, 2)); + // A chunk that Node.js accepts is discarded: only its callback runs + response.end("late", () => { + resolve("called"); + }); + }, + ); + const received = await fetchRaw(url, { + headers: { "Accept-Encoding": accept }, + }); + const label = `${name} (${started})`; + + deepStrictEqual( + [...states, await within(called)], + [native((response) => call(response)).slice(0, 2), "called"], + label, + ); + deepStrictEqual( + [received.status, received.body], + accept === "gzip" ? [200, body] : [406, ""], + label, + ); + } + } + }); + + test("by `end()` leaves the socket as it found it: the reply that follows is sent at once", async () => { + // Streamed as it is: more than the buffers of the socket hold + const large = text(1 << 20); + // By path: how the response is written before the refused `end()`, and + // the `Accept-Encoding` of the request + const cases: [string, string, unknown][] = [ + ["/pending", "gzip", {}], + ["/pending-stream", "identity", {}], + ["/as-is", "identity", 42], + ["/compressed", "gzip", {}], + ]; + for (const [path, accept, chunk] of cases) { + const corked: unknown[] = []; + const url = await start( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const socket = response.socket as Socket; + if (path === "/as-is" || path === "/compressed") { + response.write(body); + } + const before = socket.writableCorked; + corked.push(refusal(response, () => response.end(chunk))[0]); + // Corked by Node.js for the write before it, until the next tick + corked.push(socket.writableCorked - before); + if (path === "/pending-stream") { + await response.send(Readable.from([large])); + return; + } + // Once the socket has written the chunks before it + await response.flush(); + response.end(" end"); + }, + ); + const received = await fetchRaw(url, { + headers: { "Accept-Encoding": accept }, + }); + + deepStrictEqual( + [...corked, received.status, received.body.length], + [ + "ERR_INVALID_ARG_TYPE", + 0, + 200, + path === "/pending-stream" + ? large.length + : path === "/pending" + ? 4 + : body.length + 4, + ], + path, + ); + } + }); + + test("by `end()` of a response that the application has ended as it is: as without a compression", async () => { + const outcomes: unknown[][] = []; + for (const compressed of [true, false]) { + const outcome: unknown[] = []; + const { promise: done, resolve } = Promise.withResolvers(); + const middlewares: IMiddleware[] = [ + (context: Context): void => { + const { response } = context; + context.done = true; + response.on("error", (error: Error & { code?: unknown }) => { + resolve(error.code); + }); + response.type("text/plain").end(body); + outcome.push(refusal(response, () => response.end({})).slice(0, 2)); + }, + ]; + const url = await start( + {}, + ...(compressed ? [new Compression({ threshold: 0 })] : []), + ...middlewares, + ); + const received = await fetchRaw(url); + outcome.push(await within(done), received.status, received.body); + outcomes.push(outcome); + } + + deepStrictEqual(outcomes[0], outcomes[1]); + }); + + test("by `end()` of a response destroyed before its head, ended as it is or not: as without a compression (H31-1)", async () => { + // The chunks that `end()` of Node.js refuses (a falsy one is none) + const chunks = refused.filter(([, chunk]) => chunk !== null); + const outcomes = new Map(); + for (const compressed of [true, false]) { + for (const ended of [true, false]) { + for (const [name, chunk] of chunks) { + const key = `${String(ended)} ${name}`; + const { promise: closed, resolve } = Promise.withResolvers(); + const url = await start( + {}, + ...(compressed ? [new Compression({ format: "gzip" })] : []), + (context: Context) => { + const { response } = context; + context.done = true; + response.once("close", () => { + resolve(null); + }); + response.type("text/plain").destroy(); + if (ended) { + response.end(body); + } + outcomes.set(`${String(compressed)} ${key}`, [ + ...refusal(response, () => response.end(chunk)).slice(0, 2), + response.writableEnded, + ]); + }, + ); + await fetchRaw(url, { headers: gzip }).catch(() => null); + await within(closed); + } + } + } + for (const ended of [true, false]) { + for (const [name, chunk] of chunks) { + const key = `${String(ended)} ${name}`; + // A response that has ended ignores it, as Node.js does + const expected = ended + ? ["not thrown", true] + : [...native((response) => response.end(chunk)).slice(0, 2), false]; + deepStrictEqual(outcomes.get(`false ${key}`), expected, key); + deepStrictEqual(outcomes.get(`true ${key}`), expected, key); + } + } + }); + + test("by `end()` is its error, before any decision; a falsy chunk is none", async () => { + const chunks: [string, unknown][] = [ + ["an object", {}], + ["an ArrayBuffer", new ArrayBuffer(8)], + ["a Uint16Array", new Uint16Array(4)], + ]; + for (const [name, chunk] of chunks) { + const expected = native((response) => response.end(chunk)); + const states: unknown[] = []; + const url = await start( + {}, + new Compression({ format: "gzip" }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + states.push(refusal(response, () => response.end(chunk))); + response.end(body); + }, + ); + const received = await fetchRaw(url, { headers: gzip }); + + deepStrictEqual( + [...states, received.headers["content-encoding"], received.body], + [expected, "gzip", body], + name, + ); + } + + // Alone, and after a compressed write + for (const [chunk, written] of [0, false, ""].flatMap( + (value): [unknown, string][] => [ + [value, ""], + [value, body], + ], + )) { + const { promise: ended, resolve } = Promise.withResolvers(); + const url = await start( + {}, + new Compression({ format: "gzip" }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + if (written !== "") { + response.write(written); + } + Reflect.apply(response.end.bind(response), response, [ + chunk, + (): void => { + resolve("called"); + }, + ]); + }, + ); + const received = await fetchRaw(url, { headers: gzip }); + + deepStrictEqual( + [ + received.status, + received.headers["content-length"], + received.headers["content-encoding"], + received.body, + await within(ended), + ], + written === "" + ? [200, "0", none, "", "called"] + : [200, none, "gzip", body, "called"], + `${String(chunk)} after ${written.length} bytes`, + ); + } + }); +}); diff --git a/packages/compression/test/conditional.test.ts b/packages/compression/test/conditional.test.ts new file mode 100644 index 00000000..5557b21b --- /dev/null +++ b/packages/compression/test/conditional.test.ts @@ -0,0 +1,749 @@ +import { BindenAbortError, type Context } from "binden"; +import { + type IReceived, + type IServed, + capture, + fetchRaw, + gzips, + 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 { Readable } from "node:stream"; +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 }, + }); + // The length of an uncompressed `200` stays (RFC 9110, section 8.6) + const expected = + get.status === 406 + ? representation(get) + : { ...representation(get), status: 304, encoding: 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: as it is, when the request accepts it + const small = await fetchRaw(url, { path: "/small" }); + deepStrictEqual(small.body, "small"); + deepStrictEqual(small.headers.vary, "Accept-Encoding"); + const unacceptable = await fetchRaw(url, { + path: "/small", + headers: accepts.refused, + }); + deepStrictEqual(unacceptable.status, 406); + + 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, + }); + } + } + + 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}`); + + // The compressed representation is sent whole: it has no ranges + for (const range of [ + { Range: "bytes=0-9" }, + { Range: "bytes=0-9", "If-Range": String(identity.headers.etag) }, + { Range: "bytes=0-9", "If-Range": String(gzip.headers.etag) }, + { Range: `bytes=${content.length}-` }, + ]) { + const whole = await fetchRaw(url, { + headers: { ...tagged, ...accepts.gzip, ...range }, + }); + deepStrictEqual(representation(whole), representation(gzip)); + deepStrictEqual(whole.body, content); + } + + // The ranges of the file as it is + const range = { ...tagged, ...accepts.identity, 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)); + const unsatisfiable = await fetchRaw(url, { + headers: { ...range, Range: `bytes=${content.length}-` }, + }); + deepStrictEqual(unsatisfiable.status, 416); + + // `If-Range` matches its strong tag only + const strong = await fetchRaw(url, { + headers: { ...range, "If-Range": String(identity.headers.etag) }, + }); + deepStrictEqual(strong.status, validator === null ? 200 : 206); + deepStrictEqual(strong.raw.length, validator === null ? 4096 : 10); + } + }); + + test("`sendFile()` with a redirect or an error status: the whole file, whatever the preconditions and `Range`", async () => { + const tag = `"v1"`; + served = await serve({}, new Compression(), (context: Context) => + context.response + .status(Number(context.request.headers["x-status"])) + .sendFile(file, { validator: tag }), + ); + const { url } = served; + const conditions = { + "If-None-Match": { "If-None-Match": tag }, + "If-Modified-Since": { + "If-Modified-Since": new Date(Date.now() + 86_400_000).toUTCString(), + }, + "If-Match": { "If-Match": `"other"` }, + Range: { Range: "bytes=0-9" }, + }; + // What they do to a `200` (to `GET`: `HEAD` has no ranges), by coding + const applied: Record< + "gzip" | "identity", + Record + > = { + gzip: { + "If-None-Match": 304, + "If-Modified-Since": 304, + "If-Match": 412, + Range: 200, + }, + identity: { + "If-None-Match": 304, + "If-Modified-Since": 304, + "If-Match": 412, + Range: 206, + }, + }; + for (const status of [200, 302, 404, 503]) { + for (const [name, condition] of Object.entries(conditions) as [ + keyof typeof conditions, + Record, + ][]) { + for (const coding of ["gzip", "identity"] as const) { + for (const method of ["GET", "HEAD"]) { + const label = `${method} ${status} ${name} (${coding})`; + const received = await fetchRaw(url, { + method, + headers: { + "X-Status": `${status}`, + ...accepts[coding], + ...condition, + }, + }); + const expected = + status === 200 + ? method === "HEAD" && name === "Range" + ? 200 + : applied[coding][name] + : status; + deepStrictEqual(received.status, expected, label); + if (status !== 200) { + // The file as a `200` would send it whole + deepStrictEqual( + [ + received.headers["content-encoding"], + received.headers["content-length"], + received.headers["content-range"], + received.body, + ], + coding === "gzip" + ? ["gzip", none, none, method === "GET" ? content : ""] + : [ + none, + `${content.length}`, + none, + method === "GET" ? content : "", + ], + label, + ); + } + } + } + } + } + }); + + test("`sendFile()`: the preconditions of the chosen representation", async () => { + const tag = `"v1"`; + served = await serve({}, new Compression(), (context: Context) => + context.response.sendFile(file, { validator: tag }), + ); + const { url } = served; + const received = async ( + headers: Record, + method: string, + ): Promise> => { + const response = await fetchRaw(url, { method, headers }); + return { ...representation(response), body: response.body }; + }; + const modified = String((await fetchRaw(url)).headers["last-modified"]); + const earlier = new Date(Date.parse(modified) - 1000).toUTCString(); + + for (const method of ["GET", "HEAD"]) { + const compressed = { + status: 200, + etag: `W/${tag}`, + vary: "Accept-Encoding", + encoding: "gzip", + length: none, + ranges: none, + body: method === "GET" ? content : "", + }; + // Empty: no compressor, and no `Content-Encoding` + const failed = { + ...compressed, + status: 412, + encoding: none, + length: "0", + body: "", + }; + const notModified = { ...compressed, status: 304, encoding: none }; + const cases: [Record, unknown][] = [ + [{}, compressed], + // The strong comparison: the tag of the compressed representation is weak + [{ "If-Match": tag }, failed], + [{ "If-Match": `"a", ${tag}` }, failed], + [{ "If-Match": `W/${tag}` }, failed], + // Any representation + [{ "If-Match": "*" }, compressed], + [{ "If-Unmodified-Since": earlier }, failed], + [{ "If-Unmodified-Since": modified }, compressed], + // The weak comparison + [{ "If-None-Match": tag }, { ...notModified, body: "" }], + [{ "If-None-Match": `W/${tag}` }, { ...notModified, body: "" }], + [{ "If-Modified-Since": modified }, { ...notModified, body: "" }], + [{ "If-Modified-Since": earlier }, compressed], + // `If-Match` first + [{ "If-Match": tag, "If-None-Match": tag }, failed], + ]; + const created = await gzips(async () => { + for (const [headers, expected] of cases) { + deepStrictEqual( + await received({ ...accepts.gzip, ...headers }, method), + expected, + `${method} ${JSON.stringify(headers)}`, + ); + } + }); + // Only for the compressed bodies of `GET` + deepStrictEqual(created.length, method === "GET" ? 4 : 0); + + // The file as it is, which the request accepts: its strong tag + const identity = { + ...compressed, + etag: tag, + encoding: none, + length: `${content.length}`, + ranges: "bytes", + }; + deepStrictEqual( + await received({ ...accepts.identity, "If-Match": tag }, method), + identity, + ); + deepStrictEqual( + await received( + { ...accepts.identity, "If-Match": tag, "If-None-Match": tag }, + method, + ), + { ...identity, status: 304, body: "" }, + ); + } + + // Another method: a matching `If-None-Match` fails + const other = await fetchRaw(url, { + method: "POST", + headers: { ...accepts.gzip, "If-None-Match": tag }, + }); + deepStrictEqual(representation(other), { + status: 412, + etag: `W/${tag}`, + vary: "Accept-Encoding", + encoding: none, + length: "0", + ranges: none, + }); + }); + + test("the dynamic `ETag`: the preconditions of the chosen representation", async () => { + const modified = "Wed, 01 Jan 2020 00:00:00 GMT"; + const earlier = "Tue, 31 Dec 2019 23:59:59 GMT"; + served = await serve( + { etag: true }, + new Compression(), + (context: Context) => { + const { pathname } = context.url; + if (pathname === "/modified") { + context.setHeader("Last-Modified", modified); + } else if (pathname === "/weak") { + context.setHeader("ETag", `W/"weak"`); + } + return context.text(content); + }, + ); + const { url } = served; + const received = async ( + path: string, + headers: Record, + method: string, + ): Promise> => { + const response = await fetchRaw(url, { path, method, headers }); + return { ...representation(response), body: response.body }; + }; + const sent = await fetchRaw(url, { headers: accepts.gzip }); + const weak = String(sent.headers.etag); + const strong = weak.replace(/^W\//u, ""); + deepStrictEqual(sent.headers["content-encoding"], "gzip"); + ok(weak.startsWith("W/")); + + for (const method of ["GET", "HEAD"]) { + const compressed = { + status: 200, + etag: weak, + vary: "Accept-Encoding", + encoding: "gzip", + length: none, + ranges: none, + body: method === "GET" ? content : "", + }; + const failed = { + ...compressed, + status: 412, + encoding: none, + length: "0", + body: "", + }; + const notModified = { + ...compressed, + status: 304, + encoding: none, + body: "", + }; + const cases: [string, Record, unknown][] = [ + ["/", {}, compressed], + ["/", { "If-Match": strong }, failed], + ["/", { "If-Match": weak }, failed], + ["/", { "If-Match": `"other"` }, failed], + ["/", { "If-Match": "*" }, compressed], + ["/", { "If-None-Match": strong }, notModified], + ["/", { "If-None-Match": weak }, notModified], + ["/", { "If-Match": strong, "If-None-Match": strong }, failed], + // The dates of the `Last-Modified` of the response + ["/modified", { "If-Unmodified-Since": earlier }, failed], + ["/modified", { "If-Unmodified-Since": modified }, compressed], + ["/modified", { "If-Modified-Since": modified }, notModified], + ["/modified", { "If-Modified-Since": earlier }, compressed], + // A weak tag of the application never meets `If-Match` + ["/weak", { "If-Match": `W/"weak"` }, { ...failed, etag: `W/"weak"` }], + ]; + const created = await gzips(async () => { + for (const [path, headers, expected] of cases) { + deepStrictEqual( + await received(path, { ...accepts.gzip, ...headers }, method), + expected, + `${method} ${path} ${JSON.stringify(headers)}`, + ); + } + }); + deepStrictEqual(created.length, method === "GET" ? 4 : 0); + } + }); + + test("`406` takes precedence over the preconditions", async () => { + served = await serve( + { etag: true }, + new Compression(), + async (context: Context) => { + await (context.url.pathname === "/file" + ? context.response.sendFile(file, { validator: `"v1"` }) + : context.text(content)); + }, + ); + const { url } = served; + const modified = String( + (await fetchRaw(url, { path: "/file" })).headers["last-modified"], + ); + for (const path of ["/file", "/"]) { + for (const method of ["GET", "HEAD"]) { + for (const headers of [ + {}, + { "If-None-Match": "*" }, + { "If-None-Match": `"v1"` }, + { "If-Match": `"other"` }, + { "If-Modified-Since": modified }, + { "If-Unmodified-Since": "Thu, 01 Jan 1970 00:00:00 GMT" }, + ]) { + const received = await fetchRaw(url, { + path, + method, + headers: { ...accepts.refused, ...headers }, + }); + deepStrictEqual( + representation(received), + { + status: 406, + etag: none, + vary: "Accept-Encoding", + encoding: none, + length: "0", + ranges: none, + }, + `${method} ${path} ${JSON.stringify(headers)}`, + ); + deepStrictEqual(received.raw.length, 0); + } + } + } + }); + + test("`sendFile()`: a small file keeps its strong validator on `304`", async () => { + const small = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); + await writeFile(small, text(100)); + try { + served = await serve({}, new Compression(), (context: Context) => + context.response.sendFile(small, { validator: `"v1"` }), + ); + const { url } = served; + for (const method of ["GET", "HEAD"]) { + const get = await fetchRaw(url, { method, headers: accepts.gzip }); + deepStrictEqual(representation(get), { + status: 200, + etag: `"v1"`, + vary: "Accept-Encoding", + encoding: none, + length: "100", + ranges: "bytes", + }); + const conditional = await fetchRaw(url, { + method, + headers: { ...accepts.gzip, "If-None-Match": `"v1"` }, + }); + deepStrictEqual(representation(conditional), { + ...representation(get), + status: 304, + }); + } + } finally { + await rm(small); + } + }); + + test("a stream replaced with `406` is no longer read, without an error", async () => { + const big = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); + const chunk = Buffer.from(text(64 * 1024)); + const chunks = 16; + await writeFile(big, Buffer.concat(Array(chunks).fill(chunk))); + const captured = capture(); + // Per path: the client gets the empty `406` before the send of its + // request has settled, so the next request may run before it does + const outcomes = new Map(); + const settled = new Map>(); + // The reads of the source that `/stream` sends + let reads = 0; + try { + served = await serve( + { app: { logger: captured.logger } }, + new Compression(), + async (context: Context) => { + const { response } = context; + const path = context.url.pathname; + // The reads of the source of this request + let own = 0; + // A file, or a source whose chunks come at once + const source = new Readable({ + read(): void { + own += 1; + if (path === "/stream") { + reads = own; + } + this.push(own > chunks ? null : chunk); + }, + }); + const sent = + context.url.pathname === "/file" + ? response.sendFile(big) + : response + .set({ + "Content-Type": "text/plain", + "Content-Length": chunks * chunk.length, + }) + .send(source); + outcomes.set(path, [ + await sent.then( + () => null, + (error: unknown) => error, + ), + response.writableFinished, + source.destroyed || own === 0, + ]); + settled.get(path)?.resolve(null); + }, + ); + for (const path of ["/file", "/stream"]) { + const done = Promise.withResolvers(); + settled.set(path, done); + const refused = await fetchRaw(served.url, { + path, + headers: accepts.refused, + }); + deepStrictEqual(representation(refused), { + status: 406, + etag: none, + vary: "Accept-Encoding", + encoding: none, + length: "0", + ranges: none, + }); + deepStrictEqual(refused.raw.length, 0); + await within(done.promise); + } + deepStrictEqual( + [...(outcomes.get("/file") ?? []), ...(outcomes.get("/stream") ?? [])], + [null, true, true, null, true, true], + ); + ok(reads < chunks, `${reads}`); + deepStrictEqual( + captured.records.filter(({ level }) => Number(level) >= 40), + [], + ); + } finally { + await rm(big); + } + }); + + test("a `304` of the application gets the decision of a `200` of unknown length", async () => { + served = await serve({}, new Compression(), (context: Context) => { + const { response } = context; + response.set({ "Content-Type": "text/plain", ETag: `"app"` }); + return context.request.headers["if-none-match"] === `W/"app"` + ? response.status(304).send() + : context.send(large); + }); + const { url } = served; + const get = await fetchRaw(url, { headers: accepts.gzip }); + deepStrictEqual(get.headers["content-encoding"], "gzip"); + deepStrictEqual(get.body, large); + const conditional = await fetchRaw(url, { + headers: { ...accepts.gzip, "If-None-Match": String(get.headers.etag) }, + }); + deepStrictEqual(representation(conditional), { + ...representation(get), + status: 304, + encoding: none, + }); + }); + + 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(10_000), + }).end(); + const [response] = (await once(sent, "response")) as [IncomingMessage]; + deepStrictEqual(response.headers["content-encoding"], "gzip"); + await once(response, "data"); + sent.destroy(); + + // As its response has closed (before the error of its write that the + // compressor has not taken) + 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/converted-status.test.ts b/packages/compression/test/converted-status.test.ts new file mode 100644 index 00000000..76b28edc --- /dev/null +++ b/packages/compression/test/converted-status.test.ts @@ -0,0 +1,583 @@ +import { type BindenResponse, type Context } from "binden"; +import { type IServed, converse, serve } from "./helpers.js"; +import { type ServerResponse, createServer } from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import type { AddressInfo } from "node:net"; +import { Compression } from "../index.js"; +import { Readable } from "node:stream"; +import { join } from "node:path"; +import { once } from "node:events"; + +/** A file that `sendFile()` sends */ +const file = join(import.meta.dirname, "..", "package.json"); + +/** Replies a request to a path (of an `IServers`) */ +type IHandler = (response: ServerResponse, path: string) => unknown; + +/** The servers of a handler, by name, which `close()` closes */ +interface IServers { + readonly urls: Readonly>; + close(): Promise; +} + +/** + * Serves a handler by plain Node.js (`plain`), by binden alone (`binden`), and + * by binden with a compression that compresses any body with `gzip` (`gzip`) + */ +async function servers(handler: IHandler): Promise { + const middleware = async (context: Context): Promise => { + await handler(context.response, context.url.pathname); + }; + const plain = createServer((request, response) => { + Promise.resolve(handler(response, request.url ?? "/")).catch(() => null); + }); + plain.listen(0, "127.0.0.1"); + await once(plain, "listening"); + const served: IServed[] = [ + await serve({}, middleware), + await serve( + {}, + new Compression({ + threshold: 0, + format: "gzip", + filter: (): boolean => true, + }), + middleware, + ), + ]; + const [binden, gzip] = served as [IServed, IServed]; + const { port } = plain.address() as AddressInfo; + return { + urls: { + plain: `http://127.0.0.1:${String(port)}`, + binden: binden.url, + gzip: gzip.url, + }, + close: async (): Promise => { + plain.closeAllConnections(); + plain.close(); + await Promise.all([ + once(plain, "close"), + ...served.map(async (each) => each.close()), + ]); + }, + }; +} + +/** The status lines of the responses to the requests of `text`, as received */ +async function statusLines(url: string, text: string): Promise { + return (await converse(url, text)).map(({ head }) => head.split("\r\n")[0]); +} + +/** A request for a path, accepting `gzip`, after which the connection closes */ +function closing(path: string, coding = "gzip"): string { + return `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: ${coding}\r\nConnection: close\r\n\r\n`; +} + +/** A status that counts its conversions, which converts to `converted` */ +function counting(converted: number): { value: object; conversions(): number } { + let conversions = 0; + return { + value: { + [Symbol.toPrimitive](): number { + conversions += 1; + return converted; + }, + }, + conversions: () => conversions, + }; +} + +/** The code of what a call throws (`null` when it does not) */ +function thrownBy(run: () => unknown): unknown { + try { + run(); + return null; + } catch (error: unknown) { + return (error as { code?: unknown }).code; + } +} + +suite( + "The compression decides by the status as binden converts it, once, never as it is (H29-1, H29-2)", + () => { + test("a chunk in an unknown encoding of an object or a string 204 or 304 status is ignored, as by plain Node.js and binden alone, and a 200 refuses it: the status is converted as often", async () => { + const calls: Record unknown> = { + write: (response) => response.write("abc", "bogus" as never), + end: (response) => response.end("abc", "bogus" as never), + }; + const outcomes = new Map(); + const handled = await servers((response, path) => { + const [server = "", call = "", kind = "", code = ""] = path + .slice(1) + .split("-"); + const status = counting(Number(code)); + response.statusCode = ( + kind === "object" ? status.value : code + ) as never; + const thrown = thrownBy(() => (calls[call] as IHandler)(response, "")); + outcomes.set(`${server}-${call}-${kind}-${code}`, [ + thrown, + status.conversions(), + typeof response.statusCode, + ]); + if (!response.headersSent) { + response.statusCode = thrown === null ? response.statusCode : 500; + } + response.end(); + }); + const lines = new Map(); + try { + for (const [server, url] of Object.entries(handled.urls)) { + for (const call of Object.keys(calls)) { + for (const kind of ["object", "string"]) { + for (const code of ["200", "204", "304"]) { + const path = `/${server}-${call}-${kind}-${code}`; + lines.set(path, await statusLines(url, closing(path))); + } + } + } + } + } finally { + await handled.close(); + } + for (const call of Object.keys(calls)) { + for (const kind of ["object", "string"]) { + for (const code of ["200", "204", "304"]) { + const name = `${call}-${kind}-${code}`; + const gzip = outcomes.get(`gzip-${name}`); + deepStrictEqual(gzip, outcomes.get(`plain-${name}`), name); + deepStrictEqual(gzip, outcomes.get(`binden-${name}`), name); + deepStrictEqual( + gzip, + [ + code === "200" ? "ERR_UNKNOWN_ENCODING" : null, + kind === "object" ? 1 : 0, + "number", + ], + name, + ); + // binden refuses the encoding before it commits the head + deepStrictEqual( + lines.get(`/gzip-${name}`), + lines.get(`/binden-${name}`), + name, + ); + } + } + } + }); + + test("a chunk in an unknown encoding of a destroyed response, or of one that has ended (also compressed, or replaced with 406), is ignored as by plain Node.js and binden alone: its status is neither converted nor stored (H31-1)", async () => { + const calls: Record unknown> = { + write: (response) => response.write("abc", "bogus" as never), + end: (response) => response.end("abc", "bogus" as never), + }; + const prestates: Record unknown> = { + destroyed: (response) => response.destroy(), + // Ended without its head + finished: (response) => response.destroy().end("x"), + ended: (response) => response.end("x"), + }; + const statuses: Record unknown> = { + object: (status) => status, + range: () => 1000, + fractional: () => 204.5, + }; + const outcomes = new Map(); + const handled = await servers((response, path) => { + const [, call = "", prestate = "", kind = ""] = path + .slice(1) + .split("-"); + // Plain Node.js emits an `error` event for a write after the end + response.on("error", () => null); + const status = counting(201); + (prestates[prestate] as IHandler)(response, ""); + const given = statuses[kind](status.value); + response.statusCode = given as never; + const thrown = thrownBy(() => (calls[call] as IHandler)(response, "")); + outcomes.set(path, [ + thrown, + status.conversions(), + response.statusCode === given, + ]); + }); + try { + for (const call of Object.keys(calls)) { + for (const prestate of Object.keys(prestates)) { + for (const kind of Object.keys(statuses)) { + for (const coding of ["gzip", "identity;q=0, *;q=0"]) { + const name = `${call}-${prestate}-${kind}-${String(coding.length)}`; + for (const [server, url] of Object.entries(handled.urls)) { + const lines = await statusLines( + url, + closing(`/${server}-${name}`, coding), + ); + if (server === "gzip") { + // Nothing is sent of a destroyed response; the end of + // one that has ended is its reply (compressed, or + // replaced) + deepStrictEqual( + lines, + prestate === "ended" + ? [ + coding === "gzip" + ? "http/1.1 200 ok" + : "http/1.1 406 not acceptable", + ] + : [], + name, + ); + } + } + const plain = outcomes.get(`/plain-${name}`); + deepStrictEqual(plain, [null, 0, true], name); + deepStrictEqual(outcomes.get(`/binden-${name}`), plain, name); + deepStrictEqual(outcomes.get(`/gzip-${name}`), plain, name); + } + } + } + } + } finally { + await handled.close(); + } + }); + + test("the encoding is refused before the reason phrase or a Content-Length that the commit refuses, as by binden alone", async () => { + const outcomes = new Map(); + const handled = await servers((response, path) => { + const [server = "", call = "", refused = ""] = path.slice(1).split("-"); + const status = counting(200); + response.statusCode = status.value as never; + if (refused === "reason") { + response.statusMessage = "bad\r\nx: y"; + } else { + response.setHeader("Content-Length", ["1", "2"]); + } + const thrown = thrownBy(() => { + if (call === "write") { + response.write("abc", "bogus" as never); + } else { + response.end("abc", "bogus" as never); + } + }); + outcomes.set(`${server}-${call}-${refused}`, [ + thrown, + status.conversions(), + ]); + response.statusMessage = ""; + response.removeHeader("Content-Length"); + response.statusCode = 500; + response.end(); + }); + try { + for (const server of ["binden", "gzip"] as const) { + for (const call of ["write", "end"]) { + for (const refused of ["reason", "length"]) { + const path = `/${server}-${call}-${refused}`; + deepStrictEqual( + await statusLines(handled.urls[server], closing(path)), + ["http/1.1 500 internal server error"], + path, + ); + } + } + } + } finally { + await handled.close(); + } + for (const call of ["write", "end"]) { + for (const refused of ["reason", "length"]) { + const name = `${call}-${refused}`; + deepStrictEqual( + outcomes.get(`gzip-${name}`), + outcomes.get(`binden-${name}`), + name, + ); + deepStrictEqual( + outcomes.get(`gzip-${name}`), + ["ERR_UNKNOWN_ENCODING", 1], + name, + ); + } + } + }); + + test("a head refused by its reason phrase stores the status, converted once: another attempt does not convert it again, as plain Node.js (compressed or sent as it is)", async () => { + const calls: Record unknown> = { + write: (response) => response.write("abc"), + end: (response) => response.end("abc"), + flushHeaders: (response) => { + response.flushHeaders(); + }, + }; + const outcomes = new Map(); + const handled = await servers((response, path) => { + const [name = "", call = ""] = path.slice(1).split("-"); + const status = counting(201); + response.statusCode = status.value as never; + response.statusMessage = "bad\r\nx: y"; + const run = calls[call] as IHandler; + const first = thrownBy(() => run(response, "")); + const stored = typeof response.statusCode; + response.statusMessage = "Created"; + const retry = thrownBy(() => run(response, "")); + outcomes.set(name, [first, stored, retry, status.conversions()]); + response.end(); + }); + try { + for (const [server, url] of Object.entries(handled.urls)) { + for (const coding of ["gzip", "identity"]) { + for (const call of Object.keys(calls)) { + const name = `${server}|${coding}|${call}`; + deepStrictEqual( + await statusLines( + url, + closing(`/${encodeURIComponent(name)}-${call}`, coding), + ), + ["http/1.1 201 created"], + name, + ); + deepStrictEqual( + outcomes.get(encodeURIComponent(name)), + ["ERR_INVALID_CHAR", "number", null, 1], + name, + ); + } + } + } + } finally { + await handled.close(); + } + }); + + test("every call that commits the head by it (also an end() without a chunk, and any write of a response to HEAD) stores the integer, also of a string or a fractional number, as plain Node.js (C30-3, H30-1)", async () => { + const calls: Record unknown> = { + write: (response) => response.write("abc"), + end: (response) => response.end(), + empty: (response) => response.end(""), + callback: (response) => + response.end(() => { + // Never called by a refused head + }), + null: (response) => response.end(null), + chunk: (response) => response.end("abc"), + flushHeaders: (response) => { + response.flushHeaders(); + }, + writeHead: (response) => response.writeHead(response.statusCode), + }; + const statuses: Record unknown> = { + string: () => "201", + fractional: () => 201.5, + }; + const outcomes = new Map(); + const handled = await servers((response, path) => { + const [name = "", call = "", kind = ""] = path.slice(1).split("-"); + const status = counting(201); + response.statusCode = ( + kind in statuses ? statuses[kind]() : status.value + ) as never; + response.statusMessage = "bad\r\nx: y"; + const run = calls[call] as IHandler; + const first = thrownBy(() => run(response, "")); + const stored: unknown = response.statusCode; + response.statusMessage = "Created"; + const retry = thrownBy(() => run(response, "")); + outcomes.set(name, [first, stored, retry, status.conversions()]); + if (!response.writableEnded) { + response.end(); + } + }); + try { + for (const [server, url] of Object.entries(handled.urls)) { + for (const method of ["GET", "HEAD"]) { + for (const call of Object.keys(calls)) { + for (const kind of ["object", "string", "fractional"]) { + const name = `${server}|${method}|${call}|${kind}`; + const path = `/${encodeURIComponent(name)}-${call}-${kind}`; + deepStrictEqual( + await statusLines( + url, + `${method} ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n`, + ), + ["http/1.1 201 created"], + name, + ); + deepStrictEqual( + outcomes.get(encodeURIComponent(name)), + ["ERR_INVALID_CHAR", 201, null, kind === "object" ? 1 : 0], + name, + ); + } + } + } + } + } finally { + await handled.close(); + } + }); + + test("a number out of range once truncated is refused by the value given before the encoding of a chunk is checked, as plain Node.js (H30-3)", async () => { + const outcomes = new Map(); + const handled = await servers((response, path) => { + const [, call = "", status = ""] = path.slice(1).split("_"); + response.statusCode = Number(status); + let thrown: unknown = null; + try { + if (call === "write") { + response.write("abc", "bogus" as never); + } else { + response.end("abc", "bogus" as never); + } + } catch (error: unknown) { + const { code, message } = error as { + code?: unknown; + message: string; + }; + thrown = [code, message]; + } + outcomes.set(path, [thrown, response.statusCode, response.headersSent]); + response.statusCode = 500; + response.end(); + }); + try { + for (const call of ["write", "end"]) { + for (const status of [ + "1000", + "1000.5", + "99.9", + "-204", + "4294967395", + "NaN", + ]) { + const name = `${call}_${status}`; + for (const server of ["plain", "binden", "gzip"] as const) { + // Compressed, or replaced with `406` + for (const coding of ["gzip", "identity;q=0, *;q=0"]) { + const path = `/${server}_${name}_${coding.length}`; + deepStrictEqual( + await statusLines( + handled.urls[server], + closing(path, coding), + ), + ["http/1.1 500 internal server error"], + path, + ); + const plain = outcomes.get(`/plain_${name}_${coding.length}`); + deepStrictEqual(outcomes.get(path), plain, path); + deepStrictEqual( + (plain?.[0] as unknown[] | undefined)?.[0], + "ERR_HTTP_INVALID_STATUS_CODE", + path, + ); + } + } + } + } + } finally { + await handled.close(); + } + }); + + test("a statusCode that is a number but not an integer is the integer that Node.js commits: framed as that integer, and the pipelined request that follows is answered", async () => { + const replies: Record unknown> = { + send: async (response) => response.send("hello"), + stream: async (response) => response.send(Readable.from(["hello"])), + file: async (response) => response.sendFile(file), + end: (response) => response.end("hello"), + // Ignored without a body, also in an unknown encoding + write: (response) => { + response.write("hello", "bogus" as never); + response.end(); + }, + }; + const served = await serve( + {}, + new Compression({ + threshold: 0, + format: "gzip", + filter: (): boolean => true, + }), + async (context: Context): Promise => { + const [name = "", status = ""] = context.url.pathname + .slice(1) + .split("-"); + if (name === "next") { + await context.send("next"); + return; + } + context.response.statusCode = Number(status); + await replies[name](context.response); + }, + ); + /** What the client receives for a reply, and the request after it */ + const received = async (path: string): Promise => { + const responses = await converse( + served.url, + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n${closing("/next", "identity")}`, + ); + return responses + .map(({ head, body }) => + [head.replaceAll(/\r\ndate: [^\r]*/gu, ""), body.toString()].join( + "\r\n\r\n", + ), + ) + .join("|"); + }; + try { + for (const name of Object.keys(replies)) { + for (const [given, integer] of [ + [204.5, 204], + [205.5, 205], + [304.5, 304], + [200.5, 200], + [2 ** 32 + 204, 204], + ] as const) { + const expected = await received(`/${name}-${String(integer)}`); + ok(expected.endsWith("\r\n\r\nnext"), `${name} ${String(integer)}`); + deepStrictEqual( + await received(`/${name}-${String(given)}`), + expected, + `${name} ${String(given)}`, + ); + } + } + } finally { + await served.close(); + } + }); + + test("a status assigned while sendFile() opens its file is converted once, by the decision, which decides by it as binden alone", async () => { + const conversions = new Map(); + const handled = await servers(async (response, path) => { + const status = counting(204); + const sent = (response as BindenResponse).sendFile(file); + response.statusCode = status.value as never; + await sent; + conversions.set(path, status.conversions()); + }); + try { + const replies = await Promise.all( + (["binden", "gzip"] as const).map(async (server) => + (await converse(handled.urls[server], closing(`/${server}`))).map( + ({ head, body }) => [ + head.replaceAll(/\r\ndate: [^\r]*/gu, ""), + body.length, + ], + ), + ), + ); + deepStrictEqual(replies[1], replies[0]); + ok(String(replies[0]?.[0]?.[0]).startsWith("http/1.1 204 ")); + } finally { + await handled.close(); + } + deepStrictEqual(Object.fromEntries(conversions), { + "/binden": 1, + "/gzip": 1, + }); + }); + }, +); diff --git a/packages/compression/test/decision.test.ts b/packages/compression/test/decision.test.ts new file mode 100644 index 00000000..dd3af3dc --- /dev/null +++ b/packages/compression/test/decision.test.ts @@ -0,0 +1,625 @@ +import { BindenResponse, type Context, Cookie } from "binden"; +import { + type IRaw, + type IReceived, + type IRequestOptions, + type IServed, + converse, + decode, + fetchRaw, + framed, + none, + 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" }; + +/** Sends a request that accepts `gzip` (unless its headers say otherwise) */ +function fetchGzip( + url: string, + { headers = {}, ...options }: IRequestOptions = {}, +): Promise { + return fetchRaw(url, { ...options, headers: { ...gzip, ...headers } }); +} + +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 fetchGzip(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 fetchGzip(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 digests of the body are removed once it is compressed or replaced", async () => { + const digests = { + "Content-Digest": "sha-256=:YQ==:", + Digest: "sha-256=YQ==", + "Repr-Digest": "sha-256=:YQ==:", + }; + const url = await start( + (context) => { + const { pathname } = context.url; + context.response.set(digests); + if (pathname === "/304") { + // Of a compressed `200` + return context.response.status(304).send(); + } + return context.text(pathname === "/small" ? "small" : body); + }, + { + format: "gzip", + filter: (context: Context): boolean => { + if (context.url.pathname === "/filter") { + throw new Error("Failed"); + } + return true; + }, + }, + ); + for (const [path, accept, status] of [ + ["/", "gzip", 200], + ["/304", "gzip", 304], + ["/", "identity;q=0", 406], + ["/filter", "gzip", 500], + ["/small", "gzip", 200], + ["/", "identity", 200], + ] as const) { + for (const method of ["GET", "HEAD"]) { + const name = `${method} ${path} (${accept})`; + const response = await fetchRaw(url, { + method, + path, + headers: { "Accept-Encoding": accept }, + }); + deepStrictEqual(response.status, status, name); + // Kept for the uncompressed body only (`HEAD` sends no content: only + // the digests of its representation) + deepStrictEqual( + ["content-digest", "digest", "repr-digest"].map( + (header) => response.headers[header], + ), + path === "/small" || accept === "identity" + ? [ + method === "HEAD" ? none : digests["Content-Digest"], + digests.Digest, + digests["Repr-Digest"], + ] + : [none, none, none], + name, + ); + } + } + }); + + 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 fetchGzip(url); + deepStrictEqual(whole.headers["content-encoding"], "gzip"); + + for (const path of ["/partial", "/range"]) { + const response = await fetchGzip(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 fetchGzip(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 fetchGzip(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 fetchGzip(url, { + path: "/small", + headers: { "x-encoding": "identity" }, + }); + ok(!("content-encoding" in small.headers)); + deepStrictEqual(small.body, "small"); + + // When the request accepts it (see `test/acceptable.test.ts`) + const precompressed = await fetchGzip(url, { + headers: { "x-encoding": "br", "Accept-Encoding": "gzip, br" }, + }); + deepStrictEqual(precompressed.headers["content-encoding"], "br"); + deepStrictEqual(precompressed.raw.toString(), body); + + for (const encoding of ["identity", "IDENTITY, identity"]) { + const response = await fetchGzip(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 fetchGzip(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 fetchGzip(url, { + headers: { "x-cache-control": value }, + }); + ok(!("content-encoding" in response.headers), value); + deepStrictEqual(response.raw.toString(), body); + } + const other = await fetchGzip(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 fetchGzip(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 fetchGzip(url, { path: "/late" }); + deepStrictEqual(late.headers["content-encoding"], "gzip"); + deepStrictEqual(late.body, body); + + const other = await fetchGzip(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 fetchGzip(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 fetchGzip(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 fetchGzip(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 fetchGzip(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 fetchGzip(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), "127.0.0.1"); + 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("`Content-Length` is read as binden frames the body by it: equal values are that length, an empty array is none", async () => { + /** The `Content-Length` of each path (none when `null`), and the size of its body */ + const lengths: Record = + { + "/single": ["100", 100], + "/equal": [["100", "100"], 100], + "/empty": [[], 5000], + "/none": [null, 5000], + }; + const url = await start( + (context) => { + const { response } = context; + const [length, size] = lengths[context.url.pathname] ?? [null, 0]; + context.done = true; + response.setHeader("Content-Type", "text/plain"); + if (length !== null) { + response.setHeader("Content-Length", length); + } + response.write(text(size)); + response.end(); + }, + { format: "gzip", threshold: 1024 }, + ); + const decisions: Record = {}; + for (const path of Object.keys(lengths)) { + const responses = await converse( + url, + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n`, + ); + deepStrictEqual(responses.length, 1, path); + const [response] = responses as [IRaw]; + const coding = /\r\ncontent-encoding: (?[^\r]*)/u.exec( + response.head, + )?.groups?.coding; + decisions[path] = { + coding, + vary: /\r\nvary: (?[^\r]*)/u.exec(response.head)?.groups?.vary, + lengths: [ + ...response.head.matchAll(/\r\ncontent-length: (?[^\r]*)/gu), + ].map((match) => match.groups?.length), + body: decode(coding, framed(response)).toString(), + }; + } + // As for a single value (below the threshold), and as without one + const kept = (values: string[], size: number): unknown => ({ + coding: none, + vary: "accept-encoding", + lengths: values, + body: text(size), + }); + const compressed = { + coding: "gzip", + vary: "accept-encoding", + lengths: [], + body: text(5000), + }; + deepStrictEqual(decisions, { + "/single": kept(["100"], 100), + "/equal": kept(["100", "100"], 100), + "/empty": compressed, + "/none": compressed, + }); + }); + + 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 fetchGzip(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 fetchGzip(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 fetchGzip(url, { path: "/flat" }); + deepStrictEqual(flat.status, 201); + deepStrictEqual(flat.headers["content-encoding"], "gzip"); + deepStrictEqual(flat.body, body); + + const pairs = await fetchGzip(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 fetchGzip(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 fetchGzip(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 fetchGzip(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 fetchGzip(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 fetchGzip(url); + + deepStrictEqual(response.status, 400); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.body, body); + }); +}); diff --git a/packages/compression/test/early-hints.test.ts b/packages/compression/test/early-hints.test.ts new file mode 100644 index 00000000..bf1b407e --- /dev/null +++ b/packages/compression/test/early-hints.test.ts @@ -0,0 +1,266 @@ +import { + type IRaw, + type IServed, + converse, + decode, + framed, + none, + serve, + sleep, + text, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Compression } from "../index.js"; +import type { Context } from "binden"; +import { Readable } from "node:stream"; +import { readFileSync } from "node:fs"; +import zlib from "node:zlib"; + +const hints = { link: "; rel=preload; as=style" }; +const body = Buffer.from(text(2048)); + +/** A promise with its `resolve()` */ +function gate(): { promise: Promise; open(): void } { + const { promise, resolve } = Promise.withResolvers(); + return { promise, open: resolve as () => void }; +} + +/** The compressed replies of bytes, by path: their body */ +const replies: Partial< + Record Promise | Buffer> +> = { + "/send": async (context) => { + await context.send(body); + return body; + }, + "/text": async (context) => { + await context.text(body.toString()); + return body; + }, + "/write-head": ({ response }) => { + // Its head is committed before the body: Node.js queues it after the 103 + response.writeHead(200, { "Content-Type": "text/plain" }).end(body); + return body; + }, + "/write": ({ response }) => { + response.setHeader("Content-Type", "text/plain"); + response.write(new Uint8Array(body)); + response.end(); + return body; + }, + "/stream": async (context) => { + await context.response + .type("text/plain") + .send(Readable.from([body.subarray(0, 1024), body.subarray(1024)])); + return body; + }, + "/file": async (context) => { + await context.response.sendFile(import.meta.filename, { + content_type: "text/plain", + }); + return readFileSync(import.meta.filename); + }, +}; + +suite("Early hints of a pipelined request with a compressed reply", () => { + let served: IServed | null = null; + // The first request replies once the second has replied: the response of + // the second (with its `103 Early Hints`) is queued until then + const replied = { current: gate() }; + const expected = new Map(); + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + const start = async (): Promise => { + served = await serve( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/first") { + await replied.current.promise; + await sleep(20); + await context.send("first"); + return; + } + context.response.writeEarlyHints(hints); + const reply = replies[pathname]?.(context); + replied.current.open(); + expected.set(pathname, await (reply as Promise)); + }, + ); + return served.url; + }; + + for (const version of ["1.1", "1.0"]) { + test(`are sent before the response, never inside its body (HTTP/${version})`, async () => { + const url = await start(); + for (const path of Object.keys(replies)) { + replied.current = gate(); + const keep = version === "1.0" ? "Connection: keep-alive\r\n" : ""; + const responses = await converse( + url, + `GET /first HTTP/${version}\r\nHost: a\r\n${keep}\r\n` + + `GET ${path} HTTP/${version}\r\nHost: a\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n`, + ); + // An HTTP/1.0 client gets no informational response (RFC 9110, 15.2) + deepStrictEqual(responses.length, version === "1.0" ? 2 : 3, path); + const [first, ...rest] = responses as [IRaw, ...IRaw[]]; + const early = version === "1.0" ? null : rest[0]; + const second = rest[rest.length - 1]; + ok(first.head.startsWith("http/1.1 200 "), path); + deepStrictEqual(first.body.toString(), "first", path); + if (early !== null) { + deepStrictEqual( + [early.head, early.body.length], + [`http/1.1 103 early hints\r\nlink: ${hints.link}`, 0], + path, + ); + } + ok(second.head.startsWith("http/1.1 200 "), path); + ok(second.head.includes("\r\ncontent-encoding: gzip"), path); + // Framed exactly (in chunks, or by the close of HTTP/1.0) + const decoded = zlib.gunzipSync(framed(second)); + ok(decoded.equals(expected.get(path) ?? Buffer.alloc(0)), path); + } + }); + } +}); + +suite( + "Early hints, then a native write in any encoding, with a compression", + () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + /** A reply that passes `callback` to the methods of Node.js `callbacks` times */ + interface ICase { + readonly callbacks: number; + reply(response: Context["response"], callback: () => void): unknown; + } + + /** Whether Node.js writes an odd hex string to a socket (see below) */ + const oddHex = Number(process.versions.node.split(".")[0]) >= 26; + // Sent as they are (below the threshold), and compressed + const sizes = { small: 100, large: 2048 }; + const cases: Record ICase> = { + "end(string, 'utf-8')": (content) => ({ + callbacks: 0, + reply: (r): unknown => r.end(content, "utf-8"), + }), + "write(base64) + end()": (content) => ({ + callbacks: 0, + reply: (r): unknown => { + r.write(Buffer.from(content).toString("base64"), "base64"); + return r.end(); + }, + }), + "end(odd hex)": (content) => ({ + callbacks: 0, + reply: (r): unknown => + r.end(`${Buffer.from(content).toString("hex")}f`, "hex"), + }), + "write(bytes, undefined, callback) + end()": (content) => ({ + callbacks: 1, + reply: (r, cb): unknown => { + r.write(Buffer.from(content), none as unknown as BufferEncoding, cb); + return r.end(); + }, + }), + "write(bytes, null, callback) + end()": (content) => ({ + callbacks: 1, + reply: (r, cb): unknown => { + r.write(Buffer.from(content), null as unknown as BufferEncoding, cb); + return r.end(); + }, + }), + "end(bytes, undefined, callback)": (content) => ({ + callbacks: 1, + reply: (r, cb): unknown => + r.end(Buffer.from(content), none as unknown as BufferEncoding, cb), + }), + }; + + test("the hints come first, and the body is exact (callbacks once)", async () => { + let replied = gate(); + const calls = new Map(); + served = await serve( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/first") { + await replied.promise; + await sleep(20); + await context.send("first"); + return; + } + const [, size = "", name = ""] = + decodeURIComponent(pathname).split("/"); + const reply = cases[name](text(sizes[size as keyof typeof sizes])); + context.response + .setHeader("Content-Type", "text/plain") + .writeEarlyHints(hints); + reply.reply(context.response, () => { + const key = `${size}/${name}`; + calls.set(key, (calls.get(key) ?? 0) + 1); + }); + replied.open(); + await sleep(50); + }, + ); + for (const [size, length] of Object.entries(sizes)) { + // Node.js 24 aborts the process when it writes an odd hex string to + // a socket (also without binden); a compressor decodes it + const tested = Object.entries(cases).filter( + ([name]) => oddHex || size === "large" || name !== "end(odd hex)", + ); + for (const [name, make] of tested) { + replied = gate(); + const key = `${size}/${name}`; + const responses = await converse( + served.url, + `GET /first HTTP/1.1\r\nHost: a\r\n\r\n` + + `GET /${size}/${encodeURIComponent(name)} HTTP/1.1\r\nHost: a\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n`, + ); + deepStrictEqual(responses.length, 3, key); + const [first, early, second] = responses as [IRaw, IRaw, IRaw]; + deepStrictEqual(first.body.toString(), "first", key); + deepStrictEqual( + [early.head, early.body.length], + [`http/1.1 103 early hints\r\nlink: ${hints.link}`, 0], + key, + ); + ok(second.head.startsWith("http/1.1 200 "), key); + // The bodies of an unknown length are compressed, as the large ones + deepStrictEqual( + second.head.includes("\r\ncontent-encoding: gzip"), + size === "large" || name.startsWith("write("), + key, + ); + const coding = /\r\ncontent-encoding: (?\w+)/u.exec( + second.head, + )?.groups?.coding; + deepStrictEqual( + decode(coding, framed(second)).toString(), + text(length), + key, + ); + // Once flushed + await sleep(20); + deepStrictEqual(calls.get(key) ?? 0, make("").callbacks, key); + } + } + }); + }, +); diff --git a/packages/compression/test/encodings.test.ts b/packages/compression/test/encodings.test.ts new file mode 100644 index 00000000..24a5e164 --- /dev/null +++ b/packages/compression/test/encodings.test.ts @@ -0,0 +1,600 @@ +import { type BindenResponse, type Context, type IMiddleware } from "binden"; +import { Compression, type ICompressionOptions } from "../index.js"; +import { + type IRaw, + type IServed, + capture, + converse, + fetchRaw, + framed, + gzips, + none, + pollUntil, + serve, + sleep, + text, + within, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import zlib from "node:zlib"; + +const body = text(4096); +const reply = text(2048); + +/** The code and the message of the error that a call throws (`["not thrown"]` without one) */ +function thrown(fn: () => unknown): unknown[] { + try { + fn(); + } catch (error: unknown) { + const { code, message } = error as { code?: unknown; message?: unknown }; + return [code, message]; + } + return ["not thrown"]; +} + +/** + * Strings in an encoding whose byte length Node.js only estimates (the name + * in any case), and the bytes that they are: invalid ones, which Node.js + * would frame by more bytes than it writes + */ +const estimated: readonly (readonly [string, BufferEncoding, string])[] = [ + ["6869zz", "hex", "hi"], + ["68692", "HEX" as BufferEncoding, "hi"], + ["aGk!!!!", "base64", "hi"], + ["aGk$$$$", "Base64url" as BufferEncoding, "hi"], +]; + +/** The body of the first response of a connection, as framed, and how many responses there are */ +function first(responses: readonly IRaw[]): [string, string, number] { + const [response] = responses as [IRaw]; + const coding = /\r\ncontent-encoding: (?\S+)/u.exec(response.head) + ?.groups?.coding; + const raw = framed(response); + return [ + coding ?? "", + (coding === "gzip" ? zlib.gunzipSync(raw) : raw).toString("latin1"), + responses.length, + ]; +} + +suite("The encoding of a chunk", () => { + const servers: IServed[] = []; + + /** Serves the middlewares, and returns the URL */ + const start = async ( + options: Parameters[0], + ...middlewares: IMiddleware[] + ): Promise => { + const served = await serve(options, ...middlewares); + servers.push(served); + return served.url; + }; + + /** + * Sends a request, and a request for `/small` pipelined after it on the + * same connection: the responses as they are read + */ + const pipelined = async ( + url: string, + path: string, + accept: string, + ): Promise => + converse( + url, + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: ${accept}\r\n\r\nGET /small HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, + ); + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("that binden refuses is its error before any decision, as without a compression", async () => { + /** The refused calls, by path */ + const calls: Record unknown> = { + "/write": (response) => response.write(body, "bogus" as BufferEncoding), + "/end": (response) => response.end(body, "bogus" as BufferEncoding), + // Bytes only + "/buffer": (response) => response.write(body, "buffer" as BufferEncoding), + "/number": (response) => + response.end(body, 42 as unknown as BufferEncoding), + }; + const outcomes = new Map(); + for (const compressed of [true, false]) { + const outcome: unknown[] = []; + const captured = capture(); + // Created before: it checks its options with a compressor + const compression = new Compression({ format: "gzip" }); + const created = await gzips(async () => { + const url = await start( + { + app: { + logger: captured.logger, + access_log: true, + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + const { code } = error as { code?: unknown }; + outcome.push(code, context.response.headersSent); + // Its own decision + await context.response.status(500).send(reply); + }, + }, + }, + ...(compressed ? [compression] : []), + async (context: Context) => { + const { response } = context; + response.type("text/plain"); + const call = calls[context.url.pathname]; + if (context.url.searchParams.has("caught")) { + outcome.push( + thrown(() => call(response)), + response.headersSent, + ); + await response.status(400).send(reply); + } else { + call(response); + } + }, + ); + for (const path of Object.keys(calls)) { + for (const query of ["", "?caught"]) { + const received = await fetchRaw(url, { + path: `${path}${query}`, + headers: { "Accept-Encoding": "gzip" }, + }); + outcome.push( + received.status, + received.headers["content-encoding"], + received.body === reply, + ); + } + } + // The access log records what has been sent + const completed = (): unknown[] => + captured.entries + .filter(({ msg }) => msg === "The request has completed") + .map(({ status }) => status); + await pollUntil(() => completed().length >= 8); + outcome.push(completed()); + }); + // One compressor by reply: none for a refused write + outcomes.set(compressed, [...outcome, created.length]); + } + + const error = ( + code: string, + message: string, + status: number, + ): unknown[] => { + // Thrown to the error handler, or caught + const reason = status === 500 ? code : [code, message]; + return [reason, false, status, "gzip", true]; + }; + const expected = [ + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: bogus", 500), + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: bogus", 400), + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: bogus", 500), + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: bogus", 400), + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: buffer", 500), + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: buffer", 400), + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: 42", 500), + ...error("ERR_UNKNOWN_ENCODING", "Unknown encoding: 42", 400), + [500, 400, 500, 400, 500, 400, 500, 400], + ]; + deepStrictEqual(outcomes.get(true), [...expected, 8]); + // Without a compression: the same, not compressed + deepStrictEqual( + outcomes.get(false), + [...expected, 0].map((value) => (value === "gzip" ? none : value)), + ); + }); + + test("that binden refuses by a later write is its error: compressed, replaced, without a body", async () => { + const refused: ((response: BindenResponse) => unknown)[] = [ + (response): unknown => response.write("x", "bogus" as BufferEncoding), + (response): unknown => response.end("x", "bogus" as BufferEncoding), + (response): unknown => + response.write("x", 42 as unknown as BufferEncoding), + (response): unknown => response.end("x", "buffer" as BufferEncoding), + // Whatever the chunk + (response): unknown => + response.write(Buffer.from("x"), "bogus" as BufferEncoding), + ]; + const outcomes: Record = {}; + for (const compressed of [true, false]) { + const url = await start( + {}, + ...(compressed ? [new Compression({ format: "gzip" })] : []), + (context: Context) => { + const { response } = context; + context.done = true; + // `end()` ends a response to `HEAD` whatever its encoding: the + // writes after it fail + response.on("error", () => null); + response.type("text/plain"); + // A status without a body: its chunks are not checked either + response.statusCode = Number(context.url.searchParams.get("status")); + const key = `${context.request.method}${context.url.search} ${String(compressed)} ${String(context.request.headers["accept-encoding"])}`; + // Before any decision (only `HEAD` does not check it), and after it + const states = refused.map((call) => thrown(() => call(response))); + response.write(body); + // Once the head is committed, its status no longer matters: Node.js + // has decided whether it writes the body + const { statusCode } = response; + response.statusCode = 304; + states.push(...refused.map((call) => thrown(() => call(response)))); + response.statusCode = statusCode; + outcomes[key] = states; + response.end(" end"); + }, + ); + for (const [method, status, accept] of [ + ["GET", 200, "gzip"], + ["GET", 200, "identity;q=0"], + ["HEAD", 200, "gzip"], + ["GET", 204, "gzip"], + ["GET", 304, "gzip"], + ] as const) { + const received = await fetchRaw(url, { + method, + path: `/?status=${status}`, + headers: { "Accept-Encoding": accept }, + }); + const key = `${method}?status=${status} ${String(compressed)} ${accept}`; + outcomes[key].push(received.status, received.body); + } + } + + const error = (encoding: string): unknown[] => [ + "ERR_UNKNOWN_ENCODING", + `Unknown encoding: ${encoding}`, + ]; + const errors = [ + error("bogus"), + error("bogus"), + error("42"), + error("buffer"), + error("bogus"), + ]; + const ignored = refused.map(() => ["not thrown"]); + const expected = (compressed: boolean): Record => ({ + // Compressed + [`GET?status=200 ${String(compressed)} gzip`]: [ + ...errors, + ...errors, + 200, + `${body} end`, + ], + // Replaced with `406`: as for a response that has not ended + [`GET?status=200 ${String(compressed)} identity;q=0`]: compressed + ? [...errors, ...errors, 406, ""] + : [...errors, ...errors, 200, `${body} end`], + // Without a body: as without a compression + [`HEAD?status=200 ${String(compressed)} gzip`]: [ + ...ignored, + ...ignored, + 200, + "", + ], + [`GET?status=204 ${String(compressed)} gzip`]: [ + ...ignored, + ...ignored, + 204, + "", + ], + [`GET?status=304 ${String(compressed)} gzip`]: [ + ...ignored, + ...ignored, + 304, + "", + ], + }); + deepStrictEqual(outcomes, { ...expected(true), ...expected(false) }); + }); + + test("`buffer` of bytes is accepted, as without a compression", async () => { + const bodies: unknown[] = []; + for (const compression of [ + new Compression({ format: "gzip", threshold: 0 }), + null, + ]) { + const url = await start( + {}, + ...(compression ? [compression] : []), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const encoding = "buffer" as BufferEncoding; + if (context.url.pathname === "/end") { + response.end(Buffer.from("abc"), encoding); + return; + } + // The first decides, the next is compressed + response.write(Buffer.from("a"), encoding); + response.write(Buffer.from("b"), encoding); + response.end(Buffer.from("c"), encoding); + }, + ); + for (const path of ["/", "/end"]) { + const received = await fetchRaw(url, { + path, + headers: { "Accept-Encoding": "gzip" }, + }); + bodies.push([received.headers["content-encoding"], received.body]); + } + } + + deepStrictEqual(bodies, [ + ["gzip", "abc"], + ["gzip", "abc"], + [none, "abc"], + [none, "abc"], + ]); + }); + + test("whose byte length Node.js estimates is written as its bytes: as it is, and compressed", async () => { + const url = await start( + {}, + new Compression({ format: "gzip", threshold: 0 }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const index = Number(context.url.searchParams.get("index")); + const [string, encoding] = estimated[index] ?? ["", "hex"]; + if (context.url.pathname === "/small") { + response.end("small"); + } else if (context.url.pathname === "/write") { + // Chunked: each chunk is framed by its bytes + response.write(string, encoding); + response.end(string, encoding); + } else { + response.end(string, encoding); + } + }, + ); + for (const [index, [string, encoding, bytes]] of estimated.entries()) { + for (const [path, accept] of [ + ["/end", "identity"], + ["/write", "identity"], + ["/end", "gzip"], + ["/write", "gzip"], + ] as const) { + const responses = await pipelined( + url, + `${path}?index=${index}`, + accept, + ); + const label = `${path} of ${string} (${encoding}, ${accept})`; + const expected = path === "/end" ? bytes : `${bytes}${bytes}`; + + deepStrictEqual( + first(responses), + [accept === "gzip" ? "gzip" : "", expected, 2], + label, + ); + if (path === "/end" && accept === "identity") { + // The length of the chunk is the one of its bytes + deepStrictEqual( + /\r\ncontent-length: (?\d+)/u.exec(responses[0].head) + ?.groups?.length, + `${bytes.length}`, + label, + ); + } + // The pipelined request is answered + deepStrictEqual(framed(responses[1]).toString(), "small"); + } + } + }); + + test("whose byte length Node.js estimates decides by its bytes: the threshold, an empty body", async () => { + const decide = async ( + options: ICompressionOptions, + string: string, + method = "GET", + ): Promise => { + const url = await start( + {}, + new Compression(options), + (context: Context) => { + context.done = true; + context.response.type("text/plain").end(string, "hex"); + }, + ); + const received = await fetchRaw(url, { + method, + headers: { "Accept-Encoding": "gzip" }, + }); + return [ + received.headers["content-encoding"], + received.headers["content-length"], + received.headers.vary, + received.body, + ]; + }; + const vary = "Accept-Encoding"; + + // An empty body (Node.js would estimate one byte): sent as it is, whatever + // the request accepts, also to `HEAD` (which Node.js sends without a + // length, as for any empty `end()`) + deepStrictEqual(await decide({ threshold: 0 }, "zz"), [ + none, + "0", + none, + "", + ]); + deepStrictEqual(await decide({ threshold: 0 }, "zz", "HEAD"), [ + none, + none, + none, + "", + ]); + // Below the threshold by its bytes, at it by the estimate (3) + deepStrictEqual(await decide({ threshold: 3 }, "6869zz"), [ + none, + "2", + vary, + "hi", + ]); + // At the threshold by its bytes, above it by the estimate (4) + deepStrictEqual(await decide({ threshold: 3 }, "686921zz"), [ + "gzip", + none, + vary, + "hi!", + ]); + // Valid strings at the boundary + deepStrictEqual(await decide({ threshold: 3 }, "6869"), [ + none, + "2", + vary, + "hi", + ]); + deepStrictEqual(await decide({ threshold: 3 }, "686921"), [ + "gzip", + none, + vary, + "hi!", + ]); + }); + + test("an empty string is no chunk of `end()`, in any encoding, as without a compression", async () => { + const outcomes: unknown[][] = []; + for (const compressed of [true, false]) { + const outcome: unknown[] = []; + const { promise: called, resolve } = Promise.withResolvers(); + const url = await start( + {}, + ...(compressed ? [new Compression({ format: "gzip" })] : []), + (context: Context) => { + const { response } = context; + context.done = true; + response.on("error", (error: Error & { code?: unknown }) => { + outcome.push(error.code); + }); + response.type("text/plain").end(body); + // Ended as it is: a chunk would be written after the end + response.end("", "hex", (error?: Error & { code?: unknown }) => { + resolve(error?.code ?? "none"); + }); + }, + ); + const received = await fetchRaw(url); + outcome.push(await within(called), received.body === body); + await sleep(20); + outcomes.push(outcome); + } + + deepStrictEqual(outcomes[0], outcomes[1]); + deepStrictEqual(outcomes[0].length, 2); + }); + + test("an explicit `Content-Length` is never replaced by the length of a chunk, after several writes", async () => { + for (const threshold of [1024, 0]) { + const url = await start( + {}, + new Compression({ format: "gzip", threshold }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + if (context.url.pathname === "/small") { + response.end("small"); + return; + } + // Which counts the bytes that each chunk is + response.strictContentLength = true; + response.setHeader("Content-Length", 5); + response.write("ab"); + response.write("6364zz", "hex"); + response.end("65", "hex"); + }, + ); + const responses = await pipelined(url, "/", "gzip"); + + deepStrictEqual( + [ + ...first(responses), + /\r\ncontent-length: (?\d+)/u.exec(responses[0].head)?.groups + ?.length, + ], + threshold === 0 ? ["gzip", "abcde", 2, none] : ["", "abcde", 2, "5"], + `threshold ${threshold}`, + ); + } + }); + + test("the callbacks of a chunk written as its bytes run once, as those of any chunk", async () => { + for (const accept of ["identity", "gzip"]) { + const calls: unknown[] = []; + const record = (name: string) => + function callback(this: unknown, ...args: unknown[]): void { + // Its kind: a write request of the socket on Node.js 24, whose + // chunk differs + const receiver = + typeof this === "object" && this !== null + ? Object.keys(this) + : this; + calls.push([name, receiver, args.length > 0 ? args[0] : "none"]); + }; + const url = await start( + {}, + new Compression({ format: "gzip", threshold: 0 }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + response.write("6869", "hex", record("hex")); + response.write("hi", record("utf8")); + response.end("21", "HEX" as BufferEncoding, record("end")); + }, + ); + const received = await fetchRaw(url, { + headers: { "Accept-Encoding": accept }, + }); + await pollUntil(() => calls.length >= 3); + await sleep(20); + + deepStrictEqual(received.body, "hihi!", accept); + // Once each, with the receiver and the error of a chunk in `utf8` + const [hex, utf8] = calls as [unknown[], unknown[]]; + deepStrictEqual( + [calls.length, hex.slice(1), (calls[2] as unknown[])[0]], + [3, utf8.slice(1), "end"], + accept, + ); + } + }); + + test("of an odd `hex` length never aborts Node.js, and frames its bytes", () => { + // In a process of its own: Node.js 24 aborts on it (`StringBytes`) + const { status, stdout, stderr } = spawnSync( + process.execPath, + ["--import", "tsx", join(import.meta.dirname, "fixtures", "odd-hex.ts")], + { encoding: "utf8", timeout: 30_000 }, + ); + + deepStrictEqual( + { status, stdout, stderr }, + { + status: 0, + stdout: [ + "200 slow", + "200 hi", + "200 hihi", + "200 hi", + "200 gzip hi", + "200 small", + "", + ].join("\n"), + stderr: "", + }, + ); + }); +}); diff --git a/packages/compression/test/filter.test.ts b/packages/compression/test/filter.test.ts new file mode 100644 index 00000000..30bdc356 --- /dev/null +++ b/packages/compression/test/filter.test.ts @@ -0,0 +1,308 @@ +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"; +import { Readable } from "node:stream"; +import { ServerResponse } from "node:http"; + +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: sent as it is when the request accepts it + const small = await fetchRaw(url, { headers: { ...gzip, "x-size": "10" } }); + deepStrictEqual(small.headers["content-length"], "10"); + deepStrictEqual(small.headers.vary, "Accept-Encoding"); + const refused = await fetchRaw(url, { + headers: { "Accept-Encoding": "gzip, identity;q=0", "x-size": "10" }, + }); + deepStrictEqual( + [refused.status, refused.headers.vary, refused.raw.length], + [406, "Accept-Encoding", 0], + ); + }); + + 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("the filter is called once, also for a reply that starts once the headers are committed", async () => { + let calls = 0; + const url = await start( + async ({ response, url: { pathname } }) => { + response.type("text/plain"); + if (pathname === "/around") { + // Not through the compression: the response is left as it is + ServerResponse.prototype.writeHead.call(response, 200); + } else { + response.flushHeaders(); + } + // A stream (that `HEAD` does not read) chooses its representation + await response.send(Readable.from([text(2048)])); + }, + { + filter: (): boolean => { + calls += 1; + return false; + }, + }, + ); + for (const path of ["/", "/around"]) { + for (const method of ["GET", "HEAD"]) { + calls = 0; + const response = await fetchRaw(url, { path, method, headers: gzip }); + deepStrictEqual( + [response.status, response.headers["content-encoding"], calls], + [200, none, path === "/" ? 1 : 0], + `${method} ${path}`, + ); + deepStrictEqual(response.body, method === "GET" ? text(2048) : ""); + } + } + }); + + 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/finalized.test.ts b/packages/compression/test/finalized.test.ts new file mode 100644 index 00000000..96ed9988 --- /dev/null +++ b/packages/compression/test/finalized.test.ts @@ -0,0 +1,151 @@ +import { type Context, type IBindenOptions, shutdown } from "binden"; +import { type IServed, gzips, pollUntil, serve, sleep } from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { Compression } from "../index.js"; +import { connect } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; +import { once } from "node:events"; +import { randomBytes } from "node:crypto"; +import type zlib from "node:zlib"; + +/** + * Resolves once `ready()` holds, or after 10 s (the assertions that follow + * show what is missing) + */ +async function until(ready: () => boolean): Promise { + await pollUntil(ready).catch(() => null); +} + +/** What has become of a reply that waits for the one before it */ +interface IOutcome { + /** Whether its compressor is destroyed */ + readonly destroyed: boolean[]; + /** Whether its response has closed */ + readonly closed: boolean; + /** How many times its cleanup has run */ + readonly cleanups: number; +} + +suite("A compressed reply queued past its deadline", () => { + const servers: IServed[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + /** + * Pipelines a slow `/` (without a deadline) and a `/next` (with a deadline + * of 50 ms) that `next` replies to through a compression: once the + * deadline of `/next` has passed while it waits for `/`, `close` ends the + * connection + */ + const queued = async ( + next: (context: Context) => Promise | void, + close: (served: IServed, socket: ReturnType) => unknown, + app: IBindenOptions = {}, + ): Promise => { + let response: Context["response"] | null = null; + let cleanups = 0; + let compressors: readonly zlib.Gzip[] = []; + // Before: it creates a compressor of its own (to check its options) + const compression = new Compression({ format: "gzip", threshold: 0 }); + await gzips(async (created) => { + compressors = created; + const served = await serve( + { app: { timeout: 50, ...app } }, + compression, + async (context: Context) => { + if (context.url.pathname === "/") { + context.setTimeout(0); + await once(context.response, "close"); + return; + } + ({ response } = context); + context.onCleanup(() => { + cleanups += 1; + }); + await next(context); + }, + ); + servers.push(served); + const socket = connect(Number(new URL(served.url).port), "127.0.0.1"); + socket.on("error", () => null); + await once(socket, "connect"); + socket.write( + "GET / HTTP/1.1\r\nHost: a\r\nAccept-Encoding: gzip\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nAccept-Encoding: gzip\r\n\r\n", + ); + // Past the deadline of `/next`, which has created its compressor + await until(() => created.length > 0); + await sleep(150); + await close(served, socket); + await until( + () => + created.every((compressor) => compressor.destroyed) && + cleanups > 0 && + (response?.closed ?? false), + ); + // Once only + await sleep(30); + }); + return { + destroyed: compressors.map((compressor) => compressor.destroyed), + closed: (response as Context["response"] | null)?.closed ?? false, + cleanups, + }; + }; + + /** Ends its reply at once: a large body that waits in the compressor */ + const ended = (context: Context): void => { + context.response.setHeader("Content-Type", "application/json"); + context.response.end(randomBytes(1024 * 1024)); + }; + + const finalized: IOutcome = { + destroyed: [true], + closed: true, + cleanups: 1, + }; + + test("ended, then the client goes away: its compressor is destroyed", async () => { + deepStrictEqual( + await queued(ended, (_served, socket) => socket.destroy()), + finalized, + ); + }); + + test("ended, then a shutdown closes its connection: its compressor is destroyed", async () => { + let settled = "pending"; + const outcome = await queued(ended, async ({ server }) => { + settled = await Promise.race([ + shutdown(server, { timeout: 50, cleanup_timeout: 200 }).then( + () => "resolved", + (error: unknown) => (error as Error).message, + ), + sleep(2000).then(() => "pending"), + ]); + }); + deepStrictEqual([outcome, settled], [finalized, "resolved"]); + }); + + test("the reply of the error handler of its deadline, then the client goes away: its compressor is destroyed", async () => { + deepStrictEqual( + await queued( + () => + new Promise(() => { + // Never settles: replied by the error handler at its deadline + }), + (_served, socket) => socket.destroy(), + { + error_handler: (context: Context): void => { + const { response } = context; + response.status(503).type("text/plain"); + // Large, and hardly compressible: it waits in the compressor + response.end(randomBytes(1024 * 1024).toString("base64")); + }, + }, + ), + finalized, + ); + }); +}); diff --git a/packages/compression/test/fixtures/detaching-filter.ts b/packages/compression/test/fixtures/detaching-filter.ts new file mode 100644 index 00000000..cb32b756 --- /dev/null +++ b/packages/compression/test/fixtures/detaching-filter.ts @@ -0,0 +1,118 @@ +// Run by `process.test.ts`, without a handler of the process: the `filter` +// of the compression, which runs inside the write of binden that decides the +// representation (after binden has counted the bytes of a view), detaches the +// `ArrayBuffer` that binden's `Buffer` shares, unless `keep`. Modes: +// ` [ending|end]`: a view given to +// `send()`, or an object-mode `Readable` of views; `0` compresses, `100000` +// sends the body as it is, `406` is a request that accepts no representation +// (the write replaces the response with an empty `406`, and so ends it); +// `ending` installs a writer of the application that ends the response +// inside each write, once the compression has taken it (e.g. the compressor +// has accepted its bytes), and `end` one that gives the chunk of each write +// to `end()` instead. The pipelined request is answered only by a +// connection that is still in sync. Prints the outcome of the send and the +// status lines that the client gets. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "binden"; +import { Compression } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const [mode = "send", decision = "0", detach = "detach", ending = ""] = + process.argv.slice(2); + +/** The errors whose stack V8 has formatted */ +const formatted = new WeakSet(); +Error.prepareStackTrace = (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; +}; + +/** The memory of the body of the first request, which the `filter` detaches */ +let victim: ArrayBuffer | null = null; +let outcome = "pending"; +const app = new Binden({ logger: new Logger({ level: "silent" }) }) + .use( + new Compression({ + threshold: decision === "100000" ? 100_000 : 0, + filter: (context: Context): boolean => { + if ( + detach === "detach" && + context.url.pathname === "/" && + victim?.detached === false + ) { + victim.transfer(); + } + return true; + }, + }), + ) + .use(async (context: Context) => { + if (context.url.pathname === "/next") { + await context.send("next"); + return; + } + const { response } = context; + response.setHeader("Content-Type", "text/plain"); + if (ending === "ending") { + const write = response.write.bind(response) as ( + ...args: unknown[] + ) => boolean; + response.write = (...args: unknown[]): boolean => { + const accepted = write(...args); + response.end(); + return accepted; + }; + } else if (ending === "end") { + response.write = (chunk: unknown, ...args: unknown[]): boolean => { + response.end(chunk); + const callback = args.find((arg) => typeof arg === "function"); + if (typeof callback === "function") { + process.nextTick(callback, null); + } + return true; + }; + } + victim = new ArrayBuffer(4096); + new Uint8Array(victim).fill(0x61); + const data = + mode === "send" + ? new Uint8Array(victim) + : Readable.from([new Uint8Array(victim), new Uint8Array(8)], { + objectMode: true, + }); + try { + await response.send(data); + outcome = "resolved"; + } catch (error: unknown) { + // Before anything reads its stack + const kept = formatted.has(error as Error) ? "formatted" : "unformatted"; + outcome = `${(error as Error).message} (${kept})`; + } + }); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const socket = connect(port, "127.0.0.1"); +let received = ""; +socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; +}); +socket.on("error", () => { + // E.g. a reset of the cut response +}); +await once(socket, "connect"); +const accepted = decision === "406" ? "identity;q=0" : "gzip"; +socket.write( + `GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: ${accepted}\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, +); +await once(socket, "close"); +server.close(); +const replies = received.match(/HTTP\/1\.1 \d{3}|next$/gu) ?? []; +process.stdout.write(`${outcome} | ${replies.join(" ")}\n`); diff --git a/packages/compression/test/fixtures/ended-chunk.ts b/packages/compression/test/fixtures/ended-chunk.ts new file mode 100644 index 00000000..62740e10 --- /dev/null +++ b/packages/compression/test/fixtures/ended-chunk.ts @@ -0,0 +1,86 @@ +// Run by `process.test.ts`: once the compression has ended a response +// (replaced with `406`, or by the end of its compressor), a chunk that Node.js +// refuses throws as for a response that has not ended, uncaught by the +// application, rather than the `error` event that Node.js emits for an ended +// one: the response has no listener of it, which would crash the process +import { type AddressInfo, connect } from "node:net"; +import { Binden, type BindenResponse, type Context } from "binden"; +import { Compression } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, () => { + process.stderr.write(`${event}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const body = "Some text to compress. ".repeat(256); +const routes: Partial< + Record Promise | void> +> = { + // Replaced with `406` by a write, or by `writeHead()` + "/write-end": (response) => { + response.write(body); + response.end(42); + }, + "/write-end-object": (response) => { + response.write(body); + response.end({}); + }, + "/write-write": (response) => { + response.write(body); + response.write({}); + }, + "/head-end": (response) => { + response.writeHead(200); + response.end(42); + }, + "/head-end-object": (response) => { + response.writeHead(200); + response.end({}); + }, + "/head-write": (response) => { + response.writeHead(200); + response.write({}); + }, + // Compressed, then ended: once the response has finished + "/compressed": async (response) => { + response.end(body); + await once(response, "finish"); + response.end({}); + }, +}; + +const app = new Binden({ logger: new Logger({ level: "silent" }) }).use( + new Compression({ format: "gzip" }), + async (context: Context) => { + context.response.type("text/plain"); + await routes[context.url.pathname]?.(context.response); + }, +); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const replies: string[] = []; +for (const path of Object.keys(routes)) { + const accept = path === "/compressed" ? "gzip" : "identity;q=0"; + const socket = connect(port, "127.0.0.1"); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.write( + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: ${accept}\r\nConnection: close\r\n\r\n`, + ); + await once(socket, "close"); + const [status = ""] = Buffer.concat(chunks).toString("latin1").split("\r\n"); + replies.push(`${path} ${status}`); +} + +server.close(); +process.stdout.write(`${replies.join("\n")}\n`); diff --git a/packages/compression/test/fixtures/filter-rejection.ts b/packages/compression/test/fixtures/filter-rejection.ts new file mode 100644 index 00000000..2de04432 --- /dev/null +++ b/packages/compression/test/fixtures/filter-rejection.ts @@ -0,0 +1,65 @@ +// 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, "127.0.0.1"); +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, + host: "127.0.0.1", + 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/fixtures/late-context.ts b/packages/compression/test/fixtures/late-context.ts new file mode 100644 index 00000000..434df00c --- /dev/null +++ b/packages/compression/test/fixtures/late-context.ts @@ -0,0 +1,131 @@ +// Run by `process.test.ts`, without a handler of the process: the compression +// is run on a context of the application whose every use throws once `run()` +// has returned (a proxy of the context, its request and its response, whose +// traps fail from then on). Every later use of them by the compression must +// throw (formatted) to its caller, or not happen, never out of any call: the +// framework then replies (here, by closing the connection), and the process +// does not crash. Prints how the connection has ended. With `request`, a +// context whose request can be read once only: the compression decides by +// what it has read as it was installed (prints the coding of the reply). +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "binden"; +import { Compression } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +/** An error of the traps */ +class TrapError extends Error {} + +/** A switch that the traps of a proxy read (see `late()`) */ +interface ISwitch { + armed: boolean; +} + +/** Throws the error of a late use */ +function fail(): never { + throw new TrapError("A late use"); +} + +/** A trap that forwards to `Reflect` until the switch is armed */ +function forward(state: ISwitch, key: keyof typeof Reflect): unknown { + const method = Reflect.get(Reflect, key) as (...values: unknown[]) => unknown; + return (...args: unknown[]): unknown => { + if (state.armed) { + return fail(); + } + return Reflect.apply(method, Reflect, args); + }; +} + +/** A proxy of a value whose traps throw once it is armed (the objects that it gives as well) */ +function late(value: object): [proxy: object, arm: () => void] { + const state: ISwitch = { armed: false }; + const proxies = new WeakMap(); + const handler: ProxyHandler = { + get(target: object, key: PropertyKey): unknown { + if (state.armed) { + return fail(); + } + const got: unknown = Reflect.get(target, key, target); + if (typeof got === "function") { + return (got as (...args: unknown[]) => unknown).bind(target); + } else if (typeof got !== "object" || got === null) { + return got; + } + const proxy = proxies.get(got) ?? new Proxy(got, handler); + proxies.set(got, proxy); + return proxy; + }, + }; + for (const key of [ + "defineProperty", + "getOwnPropertyDescriptor", + "getPrototypeOf", + "has", + "ownKeys", + "set", + ] as const) { + Reflect.set(handler, key, forward(state, key)); + } + return [ + new Proxy(value, handler), + (): void => { + state.armed = true; + }, + ]; +} + +const [mode = "proxy"] = process.argv.slice(2); +const compression = new Compression({ threshold: 0 }); +const app = new Binden({ logger: new Logger({ level: "silent" }) }).use( + async (context: Context) => { + if (mode === "request") { + // Its request read once, as the compression is installed: the decision + // of the reply reads nothing more of it + let reads = 0; + compression.run({ + get request(): Context["request"] { + reads += 1; + return reads === 1 ? context.request : fail(); + }, + response: context.response, + } as Context); + await context.send("text"); + return; + } + const [proxy, arm] = late(context); + compression.run(proxy as Context); + arm(); + // The framework replies once this middleware has returned + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }, +); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const socket = connect(port, "127.0.0.1"); +let received = ""; +socket.setEncoding("latin1").on("data", (chunk: string) => { + received += chunk; +}); +socket.on("error", () => { + // E.g. a reset of the closed response +}); +await once(socket, "connect"); +socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n", +); +await once(socket, "close"); +server.close(); +process.stdout.write( + mode === "request" + ? `${received.includes("\r\nContent-Encoding: gzip\r\n") ? "gzip" : received}\n` + : `closed ${JSON.stringify(received.slice(0, 12))}\n`, +); diff --git a/packages/compression/test/fixtures/odd-hex.ts b/packages/compression/test/fixtures/odd-hex.ts new file mode 100644 index 00000000..c00462cf --- /dev/null +++ b/packages/compression/test/fixtures/odd-hex.ts @@ -0,0 +1,114 @@ +// Run by `encodings.test.ts`: a `hex` string of an odd length (or with +// invalid digits) written through a compression, as it is (queued behind a +// pipelined response, in chunks, by its length) and compressed. Node.js 24 +// aborts the process on an odd length that reaches the socket as a string +// (`StringBytes::StorageSize`), and every version frames an invalid string +// by more bytes than it writes: each response must be framed by its bytes, +// so that the pipelined responses that follow it are read as they are sent. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "binden"; +import { Compression } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; +import { setTimeout as sleep } from "node:timers/promises"; +import zlib from "node:zlib"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const app = new Binden({ logger: new Logger({ level: "silent" }) }).use( + new Compression({ format: "gzip", threshold: 0 }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + const { pathname } = context.url; + if (pathname === "/slow") { + // The responses after it are queued until it has ended + await sleep(50); + response.end("slow"); + } else if (pathname === "/end" || pathname === "/gzip") { + response.end("68692", "hex"); + } else if (pathname === "/write") { + response.write("68692", "hex"); + response.end("68692", "hex"); + } else if (pathname === "/invalid") { + response.end("6869zz", "hex"); + } else { + response.end("small"); + } + }, +); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const requests = [ + ["/slow", "identity"], + ["/end", "identity"], + ["/write", "identity"], + ["/invalid", "identity"], + ["/gzip", "gzip"], + ["/small", "identity"], +] as const; +const socket = connect(port, "127.0.0.1"); +const chunks: Buffer[] = []; +socket.on("data", (chunk: Buffer) => chunks.push(chunk)); +socket.write( + requests + .map( + ([path, accept], index) => + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: ${accept}\r\n${index === requests.length - 1 ? "Connection: close\r\n" : ""}\r\n`, + ) + .join(""), +); +await once(socket, "close"); +server.close(); + +// Each response by its framing: its `Content-Length`, or its chunks +let rest = Buffer.concat(chunks); +const lines: string[] = []; +while (rest.length > 0) { + const end = rest.indexOf("\r\n\r\n"); + if (end === -1) { + lines.push(`Not a response: ${JSON.stringify(rest.toString("latin1"))}`); + break; + } + const head = rest.subarray(0, end).toString("latin1").toLowerCase(); + rest = rest.subarray(end + 4); + const status = head.slice(9, 12); + const coding = /\r\ncontent-encoding: (?\S+)/u.exec(head)?.groups + ?.coding; + const length = /\r\ncontent-length: (?\d+)/u.exec(head)?.groups + ?.length; + const parts: Buffer[] = []; + if (typeof length === "undefined") { + for (;;) { + const index = rest.indexOf("\r\n"); + const size = Number.parseInt(rest.subarray(0, index).toString(), 16); + if (index === -1 || Number.isNaN(size)) { + // Not a chunk: what follows is not read + rest = Buffer.alloc(0); + break; + } + rest = rest.subarray(index + 2); + if (size === 0) { + rest = rest.subarray(2); + break; + } + parts.push(rest.subarray(0, size)); + rest = rest.subarray(size + 2); + } + } else { + parts.push(rest.subarray(0, Number(length))); + rest = rest.subarray(Number(length)); + } + const body = Buffer.concat(parts); + const decoded = coding === "gzip" ? zlib.gunzipSync(body) : body; + lines.push( + `${status} ${typeof coding === "string" ? `${coding} ` : ""}${decoded.toString()}`, + ); +} +process.stdout.write(`${lines.join("\n")}\n`); diff --git a/packages/compression/test/fixtures/pipe-failure.ts b/packages/compression/test/fixtures/pipe-failure.ts new file mode 100644 index 00000000..beee532d --- /dev/null +++ b/packages/compression/test/fixtures/pipe-failure.ts @@ -0,0 +1,78 @@ +// Run by `pipes.test.ts`: a native pipe or `pipeline()` into a compressed +// response whose head Node.js refuses (its first chunk commits the head) must +// neither crash the process nor be left to a deadline (there is none): the +// middleware that has piped it never settles, and the request gets one `500`. +// `pipeline()` rejects with the original error. +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context } from "binden"; +import { Readable, pipeline } from "node:stream"; +import { Compression } from "../../index.js"; +import { once } from "node:events"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, (error: unknown) => { + process.stderr.write(`${event} ${String(error)}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const invalid = "bad\n"; +const rejected: string[] = []; +const routes: Partial void>> = { + "/pipe": ({ response }) => { + Readable.from(["body"]).pipe(response); + }, + "/pipeline": ({ response }) => { + pipeline(Readable.from(["body"]), response, (error) => { + rejected.push(`pipeline:${(error as { code?: string } | null)?.code}`); + }); + }, +}; + +const app = new Binden() + .use(new Compression({ threshold: 0 })) + .use(async (context: Context) => { + context.response.type("text/plain"); + context.response.statusMessage = invalid; + routes[context.url.pathname]?.(context); + await new Promise(() => { + // Never settles + }); + }); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +/** The status lines received on a connection, and whether it was closed */ +async function exchange(path: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + }); + socket.write( + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n`, + ); + const closed = once(socket, "close").then(() => "closed"); + const state = await Promise.race([ + closed, + new Promise((resolve) => { + setTimeout(resolve, 300, "open"); + }), + ]); + socket.destroy(); + const statuses = data.match(/HTTP\/1\.1 \d{3}/gu) ?? []; + return `${statuses.map((line) => line.slice(9)).join("+")}(${String(state)})`; +} + +const results: string[] = []; +for (const path of Object.keys(routes)) { + results.push(await exchange(path)); +} +server.close(); +process.stdout.write(`${results.join(" ")}\n${rejected.join(" ")}\n`); diff --git a/packages/compression/test/fixtures/refused-head.ts b/packages/compression/test/fixtures/refused-head.ts new file mode 100644 index 00000000..264e43f6 --- /dev/null +++ b/packages/compression/test/fixtures/refused-head.ts @@ -0,0 +1,107 @@ +// Run by `process.test.ts`: a reason phrase, a header or a status that Node.js +// refuses once a compression has taken its decision must neither crash the +// process (on every supported version of Node.js) nor send compressed bytes +// after the reply of the error, an empty `500` +import { type AddressInfo, connect } from "node:net"; +import { Binden, type BindenResponse, type Context } from "binden"; +import { Compression } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { Readable } from "node:stream"; +import { once } from "node:events"; + +for (const event of ["unhandledRejection", "uncaughtException"]) { + process.on(event, () => { + process.stderr.write(`${event}\n`); + process.exit(1); + }); +} +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const invalid = "bad\n"; +const body = "Some text to compress. ".repeat(256); +const routes: Partial< + Record Promise | void> +> = { + "/reason": (response) => { + response.writeHead(200, invalid); + }, + "/header": (response) => { + response.writeHead(200, { "X-A": invalid }); + }, + "/end": (response) => { + response.statusMessage = invalid; + response.end(body); + }, + "/write": (response) => { + response.statusMessage = invalid; + response.write(body); + }, + "/send": async (response) => { + response.statusMessage = invalid; + await response.send(body); + }, + "/stream": async (response) => { + response.statusMessage = invalid; + await response.send(Readable.from([body])); + }, + "/file": async (response) => { + response.statusMessage = invalid; + await response.sendFile(import.meta.filename, { + content_type: "text/plain", + }); + }, + "/flush": async (response) => { + response.statusMessage = invalid; + await response.flush(); + }, + "/status": (response) => { + response.statusCode = 1000; + response.end(body); + }, + "/trailer": (response) => { + // Of a body that is not chunked: of HTTP/1.0 + response.setHeader("Trailer", "X-T"); + response.end(body); + }, +}; + +const app = new Binden({ logger: new Logger({ level: "silent" }) }).use( + new Compression({ format: "gzip" }), + async (context: Context) => { + context.response.type("text/plain"); + await routes[context.url.pathname]?.(context.response); + }, +); +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const replies: string[] = []; +for (const version of ["1.1", "1.0"]) { + // Chunked (HTTP/1.1), its trailers are valid + const paths = Object.keys(routes).filter( + (path) => version === "1.0" || path !== "/trailer", + ); + for (const path of paths) { + const socket = connect(port, "127.0.0.1"); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.write( + `GET ${path} HTTP/${version}\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n`, + ); + await once(socket, "close"); + const received = Buffer.concat(chunks).toString("latin1"); + const index = received.indexOf("\r\n\r\n"); + const [status = ""] = received.split("\r\n"); + // Nothing after the head of the empty reply + replies.push(`${status} (${received.length - index - 4} bytes)`); + } +} + +server.close(); +process.stdout.write( + `${[...new Set(replies)].join(", ")} (${replies.length})\n`, +); diff --git a/packages/compression/test/flush.test.ts b/packages/compression/test/flush.test.ts new file mode 100644 index 00000000..2b81f899 --- /dev/null +++ b/packages/compression/test/flush.test.ts @@ -0,0 +1,415 @@ +import { + Compression, + type ICompressEncodings, + isCompressible, +} from "../index.js"; +import type { ContentType, Context } from "binden"; +import { + type IServed, + decode, + fetchRaw, + pollUntil, + sameBytes, + serve, + 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 => { + await pollUntil(() => received.includes(expected)).catch(() => { + throw new Error(`Not received within 10 s: ${received}`); + }); + }, + }; +} + +/** + * Decompresses what a compressor has flushed (a stream that is not ended): + * what the decompressor has emitted once its own flush has been called back + * (it has decompressed all that it has been written), not after a time, + * which a loaded machine may need more of + */ +async function flushed(coding: string, data: Buffer): Promise { + const stream = decompressor(coding); + const read = reader(stream); + stream.write(data); + await within( + new Promise((resolve) => { + (stream as zlib.Zlib & Transform).flush(() => { + resolve(); + }); + }), + ); + 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(10_000), + }).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)); + // Sent as it is, which the request accepts + deepStrictEqual(response.headers.vary, "Accept-Encoding"); + 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", + headers: { "Accept-Encoding": "gzip" }, + }); + deepStrictEqual(head.headers["content-encoding"], "gzip"); + const ended = await fetchRaw(served.url, { + path: "/ended", + headers: { "Accept-Encoding": "gzip" }, + }); + 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, { + headers: { "Accept-Encoding": "gzip" }, + }); + 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, { "Accept-Encoding": "gzip" }); + 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, { "Accept-Encoding": "gzip" }); + const read = reader(response.pipe(zlib.createGunzip())); + await read.until("data: 1\n\n"); + response.destroy(); + + deepStrictEqual(await within(outcome), "rejected"); + }); +}); diff --git a/packages/compression/test/formatted.test.ts b/packages/compression/test/formatted.test.ts new file mode 100644 index 00000000..a59b5fa3 --- /dev/null +++ b/packages/compression/test/formatted.test.ts @@ -0,0 +1,494 @@ +import { + type BindenResponse, + ContentType, + type Context, + Middleware, +} from "binden"; +import { Compression, isCompressible, skipCompression } from "../index.js"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { fetchRaw, serve, within } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Readable } from "node:stream"; +import { Socket } from "node:net"; + +/** An error of the application's own (e.g. of a getter, or of a trap) */ +class OwnError extends Error {} + +/** What a call has thrown (or rejected with), and whether it was formatted then */ +interface IOutcome { + readonly thrown: unknown; + readonly formatted: boolean; +} + +/** + * Runs a call while `Error.prepareStackTrace` records each error whose stack + * V8 formats: what it throws, and whether that was formatted before the + * caller got it (`null` when it does not throw) + */ +function outcome(run: () => unknown): IOutcome | null { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + run(); + return null; + } catch (thrown: unknown) { + return { + thrown, + formatted: typeof thrown === "object" && formatted.has(thrown as object), + }; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A revoked proxy */ +function revoked(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** An object whose `toString()`, `valueOf()` and `@@toPrimitive` count their calls */ +function counting(): { value: object; conversions(): number } { + let conversions = 0; + const convert = (): string => { + conversions += 1; + return "utf8"; + }; + return { + value: { + toString: convert, + valueOf: convert, + [Symbol.toPrimitive]: convert, + }, + conversions: () => conversions, + }; +} + +suite( + "The entry points of the compression format what they throw (round 27)", + () => { + test("new Compression(): anything but an object is refused, and what reading the options or creating a compressor throws is formatted", () => { + for (const [options, message] of [ + [null, "`options` is not an object: null"], + [5, "`options` is not an object: 5"], + [Symbol("s"), "`options` is not an object: (a symbol)"], + ] as const) { + const failed = outcome(() => new Compression(options as never)); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + [message, true], + ); + } + for (const options of [ + revoked(), + { gzip: { level: 99 } }, + { brotli: revoked() }, + ]) { + const failed = outcome(() => new Compression(options)); + ok(failed?.thrown instanceof Error); + ok(failed.formatted); + } + for (const make of [ + (own: Error): object => ({ + get format(): string { + throw own; + }, + }), + (own: Error): object => ({ + brotli: { + get params(): object { + throw own; + }, + }, + }), + ]) { + const own = new OwnError("own"); + deepStrictEqual( + outcome(() => new Compression(make(own) as never)), + { thrown: own, formatted: true }, + ); + } + }); + + test("isCompressible(), skipCompression() and run() format what reading their argument throws", () => { + for (const run of [ + (): unknown => isCompressible({} as ContentType), + (): unknown => isCompressible(revoked() as ContentType), + (): unknown => { + skipCompression(revoked() as BindenResponse); + return null; + }, + (): unknown => { + new Compression().run(null as unknown as Context); + return null; + }, + ]) { + const failed = outcome(run); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + } + deepStrictEqual( + isCompressible(new ContentType({ type: "text/plain" })), + true, + ); + }); + + test("the methods that it installs on a response format what they throw, and describe an encoding without converting it", async () => { + const results: unknown[] = []; + const counter = counting(); + const served = await serve( + { app: { timeout: 5000 } }, + new Compression({ threshold: 0 }), + new (class extends Middleware { + // eslint-disable-next-line class-methods-use-this -- a middleware of the test + public run(context: Context): void { + const { response } = context; + for (const run of [ + (): unknown => response.write("x", Object.create(null) as never), + (): unknown => response.write("x", counter.value as never), + (): unknown => response.end("x", Symbol("s") as never), + (): unknown => response.write(null), + (): unknown => response.end({}), + (): unknown => response.writeHead(Symbol("s") as never), + ]) { + const failed = outcome(run); + results.push( + failed?.thrown instanceof Error + ? [failed.thrown.message, failed.formatted] + : failed, + ); + } + response.end("done"); + } + })(), + ); + try { + const { status, body } = await within( + fetchRaw(served.url, { headers: { "accept-encoding": "gzip" } }), + ); + deepStrictEqual([status, body], [200, "done"]); + } finally { + await served.close(); + } + deepStrictEqual(results.slice(0, 3), [ + ["Unknown encoding: (an object)", true], + ["Unknown encoding: (an object)", true], + ["Unknown encoding: (a symbol)", true], + ]); + deepStrictEqual( + results.slice(3).map((result) => (result as unknown[])[1]), + [true, true, true], + ); + deepStrictEqual(counter.conversions(), 0); + }); + + test("a Content-Length of another type (an element, also a hole) is refused before the decision, never converted, as binden refuses it: the work can still reply, compressed", async () => { + const body = "Some text to compress. ".repeat(100); + const converted = { count: 0 }; + const object = { + toString(): string { + converted.count += 1; + return String(body.length); + }, + }; + const holed = [String(body.length)]; + holed[2] = String(body.length); + const lengths: [unknown, string | null][] = [ + [body.length, null], + [String(body.length), null], + [[String(body.length)], null], + [true, "true"], + [object, "(an object)"], + [[object], "(an object)"], + [holed, "undefined"], + ]; + const outcomes: unknown[] = []; + for (const [length, described] of lengths) { + const served = await serve( + {}, + new Compression({ threshold: 4096 }), + (context: Context): void => { + const { response } = context; + response.setHeader("Content-Type", "text/plain"); + response.setHeader("Content-Length", length as never); + // Converted by Node.js as it checks the value: not by binden + converted.count = 0; + const failed = outcome(() => response.write(body)); + const thrown = failed?.thrown as + { code?: unknown; message?: unknown } | undefined; + outcomes.push([ + thrown?.message ?? null, + thrown?.code ?? null, + failed?.formatted ?? null, + response.headersSent, + converted.count, + ]); + if (failed !== null) { + // Nothing has been decided: the reply is + response.removeHeader("Content-Length"); + response.write(body); + } + response.end(); + }, + ); + try { + const { headers, body: received } = await fetchRaw(served.url, { + headers: { "accept-encoding": "gzip" }, + }); + deepStrictEqual( + [headers["content-encoding"] ?? null, received], + [described === null ? null : "gzip", body], + ); + } finally { + await served.close(); + } + } + deepStrictEqual( + outcomes, + lengths.map(([, described]): unknown[] => { + if (described === null) { + return [null, null, null, true, 0]; + } + return [ + `Invalid Content-Length: ${described}`, + "ERR_HTTP_INVALID_HEADER_VALUE", + true, + false, + 0, + ]; + }), + ); + }); + + test("writeHead(), and the commit of an end() that it decides, convert the status and the header values as often as plain Node.js, and read them once (R27-R3-4)", async () => { + /** A value that counts its conversions, which converts to `converted` */ + const countingTo = ( + converted: number | string, + ): { value: object; conversions(): number } => { + let conversions = 0; + const convert = (): number | string => { + conversions += 1; + return converted; + }; + return { + value: { + toString: convert, + valueOf: convert, + [Symbol.toPrimitive]: convert, + }, + conversions: () => conversions, + }; + }; + const variants: Record< + string, + [(response: ServerResponse, value: object) => unknown, number | string] + > = { + "an invalid status": [ + (response, value): unknown => response.writeHead(value as never), + "a", + ], + "a valid status": [ + (response, value): unknown => response.writeHead(value as never), + 200, + ], + "a value": [ + (response, value): unknown => + response.writeHead(200, { "x-a": value as never }), + "a", + ], + "a value of pairs after a reason phrase": [ + (response, value): unknown => + response.writeHead(200, "OK", ["x-a", value as never]), + "a", + ], + "the status of an end()": [ + (response, value): unknown => { + response.statusCode = value as never; + return response.end("text"); + }, + 200, + ], + }; + const counts = new Map(); + const served = await serve( + {}, + new Compression({ threshold: 0 }), + (context: Context): void => { + const { response } = context; + const name = decodeURIComponent(context.url.pathname.slice(1)); + const [call, converted] = variants[name] as [ + (response: ServerResponse, value: object) => unknown, + number | string, + ]; + const counted = countingTo(converted); + try { + call(response, counted.value); + } catch { + // Refused as Node.js refuses it + } + counts.set(name, counted.conversions()); + if (!response.writableEnded) { + response.end(); + } + }, + ); + try { + for (const name of Object.keys(variants)) { + await fetchRaw(served.url, { + path: `/${encodeURIComponent(name)}`, + headers: { "accept-encoding": "gzip" }, + }); + } + } finally { + await served.close(); + } + const plain = new Map(); + for (const [name, [call, converted]] of Object.entries(variants)) { + const counted = countingTo(converted); + try { + call( + new ServerResponse(new IncomingMessage(new Socket())), + counted.value, + ); + } catch { + // Refused + } + plain.set(name, counted.conversions()); + } + deepStrictEqual(Object.fromEntries(counts), Object.fromEntries(plain)); + }); + + test("an implicit commit (a write(), an end(), flushHeaders(), a send) converts a statusCode that is not a number once, as plain Node.js, compressed or sent as it is, and the reply has that status (C28-2)", async () => { + /** A status that counts its conversions, which converts to `converted` */ + const countingTo = ( + converted: number | string, + ): { value: object; conversions(): number } => { + let conversions = 0; + return { + value: { + [Symbol.toPrimitive](): number | string { + conversions += 1; + return converted; + }, + }, + conversions: () => conversions, + }; + }; + /** Each call, for the compression, and for plain Node.js */ + const calls: Record< + string, + [ + (response: BindenResponse) => unknown, + (response: ServerResponse) => unknown, + ] + > = { + "write()": [ + (response): unknown => response.write("abc"), + (response): unknown => response.write("abc"), + ], + "end() of a chunk": [ + (response): unknown => response.end("abc"), + (response): unknown => response.end("abc"), + ], + "end()": [ + (response): unknown => response.end(), + (response): unknown => response.end(), + ], + "flushHeaders()": [ + (response): void => { + response.flushHeaders(); + }, + (response): void => { + response.flushHeaders(); + }, + ], + "send() of a string": [ + async (response): Promise => response.send("abc"), + (response): unknown => response.end("abc"), + ], + "send() of a Readable": [ + async (response): Promise => + response.send(Readable.from(["abc"])), + (response): unknown => response.write("abc"), + ], + "json()": [ + async (response): Promise => response.json("abc"), + (response): unknown => response.end(`"abc"`), + ], + }; + const statuses = [201, 204, "a"]; + const counted = new Map number>(); + const served = await serve( + {}, + new Compression({ threshold: 0 }), + async (context: Context): Promise => { + const { response } = context; + const [name = "", converted = ""] = decodeURIComponent( + context.url.pathname.slice(1), + ).split("|"); + const [call] = calls[name] as [ + (response: BindenResponse) => unknown, + unknown, + ]; + const status = countingTo( + converted === "a" ? converted : Number(converted), + ); + counted.set(context.url.pathname, () => status.conversions()); + response.statusCode = status.value as never; + try { + await call(response); + } catch { + // Refused as Node.js refuses it + response.statusCode = 500; + } + if (!response.writableEnded) { + response.end(); + } + }, + ); + try { + for (const [name, [, plainly]] of Object.entries(calls)) { + for (const converted of statuses) { + for (const coding of ["gzip", "identity"]) { + const path = `/${encodeURIComponent(`${name}|${String(converted)}`)}`; + const { status } = await fetchRaw(served.url, { + path, + headers: { "accept-encoding": coding }, + }); + const conversions = counted.get(path) as () => number; + const plain = countingTo(converted); + const node = new ServerResponse( + new IncomingMessage(new Socket()), + ); + node.statusCode = plain.value as never; + try { + plainly(node); + } catch { + // Refused + } + deepStrictEqual( + conversions(), + plain.conversions(), + `${name} ${String(converted)} ${coding}`, + ); + deepStrictEqual( + status, + converted === "a" ? 500 : converted, + `${name} ${String(converted)} ${coding}`, + ); + } + } + } + } finally { + await served.close(); + } + }); + }, +); diff --git a/packages/compression/test/framing.test.ts b/packages/compression/test/framing.test.ts new file mode 100644 index 00000000..9f612f66 --- /dev/null +++ b/packages/compression/test/framing.test.ts @@ -0,0 +1,102 @@ +import { type IServed, converse, dechunk, serve, text } from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Compression } from "../index.js"; +import type { Context } from "binden"; +import zlib from "node:zlib"; + +const body = text(2048); + +suite("The framing of a compressed body", () => { + const servers: IServed[] = []; + + /** Serves `body` through a `gzip` compression, after a middleware */ + const start = async (before: (context: Context) => void): Promise => { + const served = await serve( + {}, + new Compression({ format: "gzip" }), + (context: Context) => { + before(context); + if (context.url.pathname === "/write") { + // Without `send()`, which frames the body of binden + context.done = true; + context.response.setHeader("Content-Type", "text/plain"); + context.response.end(body); + return Promise.resolve(); + } + return context.text(body); + }, + ); + servers.push(served); + return served.url; + }; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("HTTP/1.1: in chunks, the connection kept alive, also after a stale `Transfer-Encoding`", async () => { + const url = await start((context) => { + if (context.request.headers["x-stale"] === "te") { + // E.g. copied from the response of an upstream + context.response.setHeader("Transfer-Encoding", "chunked"); + } else if (context.request.headers["x-stale"] === "removed") { + context.response.removeHeader("Transfer-Encoding"); + } + }); + for (const [path, stale] of [ + ["/", "none"], + ["/", "te"], + ["/write", "removed"], + ]) { + const responses = await converse( + url, + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nX-Stale: ${stale}\r\n\r\n` + + `HEAD ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n`, + ); + // Both, on one connection + deepStrictEqual(responses.length, 2, stale); + for (const { head } of responses) { + ok(head.startsWith("http/1.1 200 "), stale); + ok(head.includes("\r\ncontent-encoding: gzip"), stale); + ok(head.includes("\r\ntransfer-encoding: chunked"), stale); + ok(!head.includes("content-length"), stale); + } + const [get, head] = responses.map((response) => response.body); + const { body: compressed, complete } = dechunk(get); + ok(complete, stale); + deepStrictEqual(zlib.gunzipSync(compressed).toString(), body); + deepStrictEqual(head.length, 0); + } + }); + + test("HTTP/1.0: delimited by closing the connection, never in chunks", async () => { + const url = await start((context) => { + const stale = context.request.headers["x-stale"]; + if (stale === "te") { + context.response.setHeader("Transfer-Encoding", "chunked"); + } else if (stale === "keep-alive") { + context.response.setHeader("Connection", "keep-alive"); + } + }); + for (const [path, stale] of [ + ["/", "none"], + ["/write", "te"], + ["/", "keep-alive"], + ]) { + const responses = await converse( + url, + `GET ${path} HTTP/1.0\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: keep-alive\r\nX-Stale: ${stale}\r\n\r\n`, + ); + deepStrictEqual(responses.length, 1, stale); + for (const { head, body: raw } of responses) { + ok(head.includes("\r\ncontent-encoding: gzip"), stale); + ok(head.includes("\r\nconnection: close"), `${stale}: ${head}`); + ok(!head.includes("keep-alive"), stale); + ok(!head.includes("transfer-encoding"), `${stale}: ${head}`); + ok(!head.includes("content-length"), stale); + deepStrictEqual(zlib.gunzipSync(raw).toString(), body, stale); + } + } + }); +}); diff --git a/packages/compression/test/head.test.ts b/packages/compression/test/head.test.ts new file mode 100644 index 00000000..1734a457 --- /dev/null +++ b/packages/compression/test/head.test.ts @@ -0,0 +1,186 @@ +import { type IServed, fetchRaw, serve, text } from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { type BindenResponse } from "binden"; +import { Compression } from "../index.js"; +import { Readable } from "node:stream"; +import { deepStrictEqual } from "node:assert/strict"; + +/** The length of a body, as the decision sees it */ +type ILength = "at" | "below" | "empty" | "unknown"; + +/** The bodies of a threshold of 1024 bytes, by path: how they are written, and their length */ +const bodies = new Map< + string, + readonly [ILength, (response: BindenResponse) => Promise | void] +>( + Object.entries({ + "/end": [ + "empty", + (response): void => { + response.end(); + }, + ], + "/end-empty": [ + "empty", + (response): void => { + response.end(""); + }, + ], + "/end-below": [ + "below", + (response): void => { + response.end(text(1023)); + }, + ], + "/end-at": [ + "at", + (response): void => { + response.end(text(1024)); + }, + ], + "/write": [ + "unknown", + (response): void => { + response.write(text(1024)); + response.end(text(1024)); + }, + ], + "/stream-0": [ + "empty", + (response): Promise => + response.setHeader("Content-Length", 0).send(Readable.from([])), + ], + "/stream-below": [ + "below", + (response): Promise => + response + .setHeader("Content-Length", 1023) + .send(Readable.from([text(1023)])), + ], + "/stream-at": [ + "at", + (response): Promise => + response + .setHeader("Content-Length", 1024) + .send(Readable.from([text(1024)])), + ], + "/stream": [ + "unknown", + (response): Promise => + response.send(Readable.from([text(1024), text(1024)])), + ], + }), +); + +const vary = "Accept-Encoding"; +const none = ({} as { value?: undefined }).value; +const as_is = [200, none, none, none] as const; +const small = [200, none, vary, none] as const; +const compressed = [200, "gzip", vary, "chunked"] as const; +const refused = [406, none, vary, none] as const; + +/** The status, `Content-Encoding`, `Vary` and `Transfer-Encoding`, by length */ +const decisions: Record> = { + gzip: { empty: as_is, below: small, at: compressed, unknown: compressed }, + "identity;q=0": { + empty: as_is, + below: refused, + at: refused, + unknown: refused, + }, + "gzip, identity;q=0": { + empty: as_is, + below: refused, + at: compressed, + unknown: compressed, + }, +}; + +suite("`HEAD` decides as `GET`", () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("by the length of its body (empty, below or at the threshold, unknown), never `406` for an empty one", async () => { + served = await serve( + {}, + new Compression({ format: "gzip" }), + async ({ response, url }): Promise => { + response.type("text/plain"); + await bodies.get(url.pathname)?.[1](response); + }, + ); + const { url } = served; + for (const [accept, decision] of Object.entries(decisions)) { + for (const [path, [length]] of bodies) { + for (const method of ["GET", "HEAD"]) { + const name = `${method} ${path} (${accept})`; + const received = await fetchRaw(url, { + path, + method, + headers: { "Accept-Encoding": accept }, + }); + const { status, headers } = received; + deepStrictEqual( + [ + status, + headers["content-encoding"], + headers.vary, + headers["transfer-encoding"], + ], + decision[length], + name, + ); + deepStrictEqual( + received.body.length, + method === "GET" && status === 200 + ? { empty: 0, below: 1023, at: 1024, unknown: 2048 }[length] + : 0, + name, + ); + } + } + } + }); + + test("a stream that `HEAD` does not read is of an unknown length, not empty", async () => { + served = await serve( + {}, + new Compression({ format: "gzip" }), + async ({ response }): Promise => { + response.type("text/plain"); + await response.send(Readable.from([])); + }, + ); + const { url } = served; + const results: unknown[] = []; + for (const accept of Object.keys(decisions)) { + for (const method of ["GET", "HEAD"]) { + const { status, headers } = await fetchRaw(url, { + method, + headers: { "Accept-Encoding": accept }, + }); + results.push([ + method, + accept, + status, + headers["content-encoding"], + headers.vary, + ]); + } + } + // `GET` reads it to its end: an empty body + deepStrictEqual(results, [ + ["GET", "gzip", 200, none, none], + ["HEAD", "gzip", 200, "gzip", vary], + ["GET", "identity;q=0", 200, none, none], + ["HEAD", "identity;q=0", 406, none, vary], + ["GET", "gzip, identity;q=0", 200, none, none], + ["HEAD", "gzip, identity;q=0", 200, "gzip", vary], + ]); + }); +}); diff --git a/packages/compression/test/helpers.ts b/packages/compression/test/helpers.ts new file mode 100644 index 00000000..a2e3c4b2 --- /dev/null +++ b/packages/compression/test/helpers.ts @@ -0,0 +1,431 @@ +import { type AddressInfo, connect } from "node:net"; +import { Binden, type IBindenOptions, type IMiddleware } from "binden"; +import { + type IncomingHttpHeaders, + type IncomingMessage, + type OutgoingHttpHeaders, + type Server, + createServer, + request, +} from "node:http"; +import { Logger } from "@binden/logger"; +import type { Transform } from "node:stream"; +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. The + * default only guards against a hang, with a deadline that a loaded machine + * does not reach: a test that asserts a timing passes its own + */ +export async function within(promise: Promise, ms = 10_000): 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); + } +} + +/** + * Resolves after a turn of the pending I/O (a 5 ms timer, then two turns of + * `setImmediate()`): once a stall of the process (e.g. a loaded machine) has + * passed, the timers run before the I/O that has come meanwhile is read, the + * system may not have delivered it yet (e.g. the end of a loopback + * connection, which macOS delivers asynchronously), and the `close` of a + * socket whose end is read then is emitted only once the first turn has run + * (in the close callbacks of the event loop), so that silence (or an expired + * deadline) is judged only after all three + */ +export async function ioTurn(): Promise { + await sleep(5); + for (let turn = 0; turn < 2; turn += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); + } +} + +/** + * Resolves once `check()` holds (polled every 5 ms), or rejects once it has + * not within `ms` milliseconds, judged after a turn of the pending I/O (see + * `ioTurn()`): a poller that ends by itself, whoever waits for it, so that a + * failure never keeps the process of a test alive + */ +export async function pollUntil( + check: () => boolean, + ms = 10_000, +): Promise { + const deadline = Date.now() + ms; + while (!check()) { + if (Date.now() > deadline) { + await ioTurn(); + if (check()) { + return; + } + throw new Error(`Not true within ${ms} ms: ${String(check)}`); + } + await sleep(5); + } +} + +/** + * 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); +} + +/** The compressors that `createGzip()` of `node:zlib` creates while `fn` runs */ +export async function gzips( + fn: (created: readonly 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; +} + +/** The functions of `node:zlib` that create the compressor of each coding */ +const creators = [ + "createBrotliCompress", + "createDeflate", + "createGzip", + "createZstdCompress", +] as const; + +/** The compressors of every coding that `node:zlib` creates while `fn` runs */ +export async function compressors( + fn: () => Promise, +): Promise { + const created: Transform[] = []; + const originals = creators.map( + (name) => + [name, Object.getOwnPropertyDescriptor(zlib, name)] as [ + string, + PropertyDescriptor, + ], + ); + for (const [name, original] of originals) { + const create = original.value as (options?: object) => Transform; + Object.defineProperty(zlib, name, { + ...original, + value: (options?: object): Transform => { + const compressor = create(options); + created.push(compressor); + return compressor; + }, + }); + } + try { + await fn(); + } finally { + for (const [name, original] of originals) { + Object.defineProperty(zlib, name, original); + } + } + return created; +} + +/** `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 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 }[]; + /** The records logged so far, whole */ + readonly entries: Record[]; + /** 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 entries(): Record[] { + return lines.map((line) => JSON.parse(line) as Record); + }, + 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 => { + await pollUntil(() => lines.length >= count).catch(() => { + throw new Error(`${lines.length} records: ${lines.join("")}`); + }); + }, + }; +} + +/** 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` (the `etag` option of the application) */ + 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, etag }).use(...middlewares); + const server = createServer({ + ...app.serverOptions(), + rejectNonStandardBodyWrites: strict, + }); + server.on("request", app.callback(server)); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + server, + url: `http://127.0.0.1:${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(10_000) }, + (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(); + }), + ); +} + +/** The body of a chunked message, and whether it has its last chunk */ +export function dechunk(raw: Buffer): { body: Buffer; complete: boolean } { + const parts: Buffer[] = []; + let rest = raw; + for (;;) { + const index = rest.indexOf("\r\n"); + if (index === -1) { + return { body: Buffer.concat(parts), complete: false }; + } + const size = Number.parseInt(rest.subarray(0, index).toString(), 16); + if (size === 0) { + return { body: Buffer.concat(parts), complete: true }; + } + parts.push(rest.subarray(index + 2, index + 2 + size)); + rest = rest.subarray(index + 2 + size + 2); + } +} + +/** + * Sends a request on a connection that is not read until `pause` (a slow + * client): for that many milliseconds (none when `0`), or until that promise + * has settled (within 10 s, e.g. once the server has passed a deadline), + * then reads everything until the server closes it: the head of the + * response, and its body. Its listeners are added before it pauses: a paused + * connection still ends and closes when the server closes it before it has + * sent anything (e.g. once a deadline has passed on a loaded machine). + */ +export async function exchange( + url: string, + pause: Promise | number = 0, +): Promise<{ head: string; body: Buffer }> { + const socket = connect(Number(new URL(url).port), "127.0.0.1"); + await once(socket, "connect"); + socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n", + ); + // Explicitly paused: a listener of `data` does not resume it + socket.pause(); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + const closed = once(socket, "close"); + // An error of the connection fails the exchange once it is awaited below, + // never as a rejection that nothing observes meanwhile + closed.catch(() => null); + await (typeof pause === "number" ? sleep(pause) : within(pause)); + socket.resume(); + await within(closed); + const received = Buffer.concat(chunks); + const index = received.indexOf("\r\n\r\n"); + return { + head: received.subarray(0, index).toString(), + body: received.subarray(index + 4), + }; +} + +/** A response read from a connection: its head (lowercase names) and its body as sent */ +export interface IRaw { + readonly head: string; + readonly body: Buffer; +} + +/** + * Writes requests on one connection and reads everything until the server + * closes it (within 5 s): the responses, split by their head + */ +export async function converse(url: string, requests: string): Promise { + const socket = connect(Number(new URL(url).port), "127.0.0.1"); + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + const closed = once(socket, "close"); + socket.write(requests); + try { + await within(closed); + } finally { + socket.destroy(); + } + const received = Buffer.concat(chunks); + const responses: IRaw[] = []; + let start = received.indexOf("HTTP/1.1 "); + while (start !== -1) { + const end = received.indexOf("\r\n\r\n", start); + const next = received.indexOf("HTTP/1.1 ", end); + responses.push({ + head: received.subarray(start, end).toString().toLowerCase(), + body: received.subarray(end + 4, next === -1 ? received.length : next), + }); + start = next; + } + return responses; +} + +/** + * The body of a response read from a connection (see `converse()`) by its + * framing, which must be exact: its `Content-Length`, its chunks (with + * nothing after the last one), or else all until the connection closed + */ +export function framed({ head, body }: IRaw): Buffer { + const length = /\r\ncontent-length: (?\d+)/u.exec(head)?.groups + ?.length; + if (head.includes("\r\ntransfer-encoding: chunked")) { + const { body: data, complete } = dechunk(body); + if (!complete || body.subarray(-5).toString() !== "0\r\n\r\n") { + throw new Error(`Not framed by its chunks: ${body.toString("latin1")}`); + } + return data; + } else if (typeof length !== "undefined" && Number(length) !== body.length) { + throw new Error(`${body.length} bytes instead of ${length}`); + } + return body; +} diff --git a/packages/compression/test/negotiation.test.ts b/packages/compression/test/negotiation.test.ts new file mode 100644 index 00000000..0d852541 --- /dev/null +++ b/packages/compression/test/negotiation.test.ts @@ -0,0 +1,278 @@ +import { Compression, type ICompressionOptions } from "../index.js"; +import { type IServed, fetchRaw, none, serve, text } from "./helpers.js"; +import { accepts, negotiate } from "../src/negotiate.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert"; +import type { Context } from "binden"; + +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("accepts()", () => { + const cases: [string | undefined, string[], boolean][] = [ + // Any coding without the header, none with an empty one + [none, ["gzip"], true], + [none, ["*"], true], + ["", ["gzip"], false], + ["gzip", ["gzip"], true], + ["x-gzip", ["gzip"], true], + ["GZIP", ["x-gzip"], true], + ["*", ["aes128gcm"], true], + ["aes128gcm", ["aes128gcm"], true], + ["br, gzip;q=0.1", ["gzip"], true], + // Not named, or refused + ["br", ["gzip"], false], + ["identity", ["gzip"], false], + ["gzip;q=0", ["gzip"], false], + ["*;q=0", ["gzip"], false], + ["*, gzip;q=0", ["gzip"], false], + // Every coding of the representation + ["gzip, br", ["gzip", "br"], true], + ["gzip", ["gzip", "br"], false], + // Not a content coding + ["*", ["*"], false], + ["*", ["gzip;q=1"], false], + ]; + for (const [header, codings, expected] of cases) { + deepStrictEqual( + accepts(header, codings), + expected, + `${String(header)} ${codings.join()}`, + ); + } + }); + + 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 is the only coding besides `identity`", async () => { + const url = await start({ format: "deflate" }); + const cases: [string | null, number, string | undefined][] = [ + // `identity` is preferred without the header, or with an empty one + [null, 200, none], + ["", 200, none], + ["deflate", 200, "deflate"], + ["gzip, deflate;q=0.5", 200, "deflate"], + ["*", 200, "deflate"], + ["identity;q=0, *", 200, "deflate"], + // Not acceptable: `identity` then + ["gzip", 200, none], + ["deflate;q=0, identity", 200, none], + ["deflate;q=0.5, identity", 200, none], + // Neither + ["identity;q=0", 406, none], + ["gzip, identity;q=0", 406, none], + ["deflate;q=0, identity;q=0", 406, none], + ["*;q=0", 406, none], + ]; + for (const [header, status, coding] of cases) { + for (const method of ["GET", "HEAD"]) { + const response = await fetchRaw(url, { + method, + headers: header === null ? {} : { "Accept-Encoding": header }, + }); + deepStrictEqual( + [ + response.status, + response.headers["content-encoding"], + response.headers.vary, + ], + [status, coding, "Accept-Encoding"], + `${method} ${String(header)}`, + ); + deepStrictEqual( + response.body, + status === 200 && method === "GET" ? 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..b25bd1a6 --- /dev/null +++ b/packages/compression/test/options.test.ts @@ -0,0 +1,295 @@ +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 type { Context } from "binden"; +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); + } +} + +/** 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("the exports of the package", async () => { + deepStrictEqual(Object.keys(await import("../index.js")).sort(), [ + "Compression", + "default", + "isCompressible", + "skipCompression", + ]); + }); + + 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"), + ); + 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("encodings (by their indexes) and the params of `br` are read once, and copied", () => { + const reads = new Map(); + const encodings = new Proxy(["gzip", "br"], { + get(target: string[], key: PropertyKey): unknown { + if (typeof key === "string" && /^\d+$/u.test(key)) { + reads.set(key, (reads.get(key) ?? 0) + 1); + } + return Reflect.get(target, key) as unknown; + }, + }); + let params = 0; + const brotli = { + get params(): Record { + params += 1; + return { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 }; + }, + }; + const compression = new Compression({ + encodings: encodings as ICompressionOptions["encodings"], + brotli, + }); + deepStrictEqual([Object.fromEntries(reads), params], [{ 0: 1, 1: 1 }, 1]); + deepStrictEqual(compression.format, "auto"); + }); + + 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( + "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"); + }); + }); + + 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, { + headers: { "Accept-Encoding": "br" }, + }); + deepStrictEqual(response.headers["content-encoding"], "br"); + deepStrictEqual(response.body, body); + }); + + deepStrictEqual(given, [ + { + chunkSize: 32 * 1024, + params: { [BROTLI_PARAM_MODE]: 1, [BROTLI_PARAM_QUALITY]: 11 }, + }, + ]); + }); + + test("the params of `zstd` are read once, and copied: a compressed response never reads them", async () => { + const { ZSTD_c_compressionLevel } = zlib.constants; + const body = text(2048); + // What reads the params of the application, by its traps + const reads: string[] = []; + const params = new Proxy>( + { [ZSTD_c_compressionLevel]: 3 }, + { + ownKeys(target: object): (string | symbol)[] { + reads.push("ownKeys"); + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor( + target: object, + key: string | symbol, + ): PropertyDescriptor | undefined { + reads.push(`descriptor ${String(key)}`); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + get(target: object, key: string | symbol): unknown { + reads.push(`get ${String(key)}`); + return Reflect.get(target, key) as unknown; + }, + }, + ); + served = await serve( + {}, + new Compression({ format: "zstd", zstd: { params } }), + (context: Context) => context.text(body), + ); + deepStrictEqual(reads, [ + "ownKeys", + `descriptor ${String(ZSTD_c_compressionLevel)}`, + `get ${String(ZSTD_c_compressionLevel)}`, + ]); + reads.length = 0; + const given = await optionsOf("createZstdCompress", async () => { + const response = await fetchRaw((served as IServed).url, { + headers: { "Accept-Encoding": "zstd" }, + }); + deepStrictEqual(response.headers["content-encoding"], "zstd"); + deepStrictEqual(response.body, body); + }); + deepStrictEqual( + [reads, given], + [[], [{ params: { [ZSTD_c_compressionLevel]: 3 } }]], + ); + }); +}); diff --git a/packages/compression/test/ownership.test.ts b/packages/compression/test/ownership.test.ts new file mode 100644 index 00000000..0429e71c --- /dev/null +++ b/packages/compression/test/ownership.test.ts @@ -0,0 +1,216 @@ +import { Compression, skipCompression } from "../index.js"; +import { type Context, type IBindenOptions, type IMiddleware } from "binden"; +import { + type IServed, + capture, + dechunk, + exchange, + fetchRaw, + sameBytes, + serve, + sleep, + text, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Readable } from "node:stream"; +import { randomBytes } from "node:crypto"; +import zlib from "node:zlib"; + +/** A promise with its `resolve()` */ +function gate(): { promise: Promise; open(): void } { + const { promise, resolve } = Promise.withResolvers(); + return { promise, open: resolve as () => void }; +} + +suite("Compression of a response whose owner is abandoned", () => { + const servers: IServed[] = []; + + const start = async ( + app: IBindenOptions, + ...middlewares: IMiddleware[] + ): Promise => { + const served = await serve({ app }, ...middlewares); + servers.push(served); + return served; + }; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("a timed out error handler that has ended a compressed reply under backpressure: all of it is sent", async () => { + const body = randomBytes(8 * 1024 * 1024); + const states: boolean[] = []; + const captured = capture(); + const { url } = await start( + { + logger: captured.logger, + error_handler_timeout: 100, + error_handler: async (context: Context): Promise => { + const { response } = context; + response.status(500).type("text/plain"); + // Not awaited: the client reads slowly + response.send(body).catch(() => null); + states.push(response.writableEnded, response.writableFinished); + await sleep(200); + states.push(response.writableFinished); + // Once timed out + response.destroy(); + response.write("late"); + response.end("late"); + await new Promise(() => { + // Never settles + }); + }, + }, + new Compression({ format: "gzip" }), + () => { + throw new Error("Failed"); + }, + ); + const { head, body: raw } = await exchange(url, 500); + ok(head.startsWith("HTTP/1.1 500 "), head); + ok(/^content-encoding: gzip$/imu.test(head), head); + const { body: compressed, complete } = dechunk(raw); + ok(complete); + sameBytes(zlib.gunzipSync(compressed), body); + deepStrictEqual(states, [true, false, false]); + ok( + captured.records.some( + ({ msg }) => + msg === "The provided `error_handler` has not settled in time", + ), + ); + }); + + test("a `drain` listener of the abandoned work does not end the reply of the error handler", async () => { + // Not compressible: the compressor and the response are full at once + const body = randomBytes(4 * 1024 * 1024); + const pieces = Array.from({ length: 64 }, (_, index) => + body.subarray(index * 65_536, (index + 1) * 65_536), + ); + let drained = 0; + const { url } = await start( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + context.response.status(503).type("text/plain"); + // Written with backpressure: it waits for the `drain` of the + // compressor, which the response emits to every listener + await context.send(Readable.from(pieces)); + }, + }, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + response.once("drain", () => { + drained += 1; + response.end("OLD"); + }); + await new Promise(() => { + // Abandoned at its deadline + }); + }, + ); + // A slow client: the response is full + const { head, body: raw } = await exchange(url, 300); + ok(head.startsWith("HTTP/1.1 503 "), head); + const { body: compressed, complete } = dechunk(raw); + ok(complete); + sameBytes(zlib.gunzipSync(compressed), body); + deepStrictEqual(drained, 1); + }); + + test("the writes of the abandoned work are ignored while the error handler replies", async () => { + const abandoned = gate(); + const calls: string[] = []; + let synchronous = 0; + let returned: unknown[] = []; + const reply = text(4096); + const { url } = await start( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + context.response.status(503).type("text/plain"); + await abandoned.promise; + await context.send(reply); + }, + }, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + await sleep(60); + const callback = (name: string) => (): void => { + calls.push(name); + }; + returned = [ + response.write("late", callback("write")), + response.write("late", "utf8", callback("write-encoding")), + response.end("late", callback("end")) === response, + response.writeHead(200) === response, + ]; + synchronous = calls.length; + await response.flush(); + await sleep(10); + abandoned.open(); + }, + ); + const received = await fetchRaw(url, { + headers: { "Accept-Encoding": "gzip" }, + }); + deepStrictEqual( + [received.status, received.headers["content-encoding"], received.body], + [503, "gzip", reply], + ); + deepStrictEqual(synchronous, 0); + deepStrictEqual(returned, [true, true, true, true]); + deepStrictEqual(calls.toSorted(), ["end", "write", "write-encoding"]); + }); + + test("`skipCompression()` and `run()` of the abandoned work do not change the reply of the error handler", async () => { + const reply = text(4096); + const late = new Compression({ format: "gzip", threshold: 0 }); + for (const [path, accept, coding] of [ + // The reply would be refused as it is + ["/skip", "gzip, identity;q=0", "gzip"], + // The reply would be compressed + ["/run", "gzip", null], + ] as const) { + const abandoned = gate(); + const { url } = await start( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + context.response.status(503).type("text/plain"); + await abandoned.promise; + await context.send(reply); + }, + }, + ...(path === "/skip" ? [new Compression({ format: "gzip" })] : []), + async (context: Context) => { + await sleep(60); + if (path === "/skip") { + skipCompression(context.response); + } else { + late.run(context); + } + abandoned.open(); + await sleep(10); + }, + ); + const received = await fetchRaw(url, { + headers: { "Accept-Encoding": accept }, + }); + deepStrictEqual( + [ + received.status, + received.headers["content-encoding"] ?? null, + received.body, + ], + [503, coding, reply], + path, + ); + } + }); +}); diff --git a/packages/compression/test/pipes.test.ts b/packages/compression/test/pipes.test.ts new file mode 100644 index 00000000..5c885e1e --- /dev/null +++ b/packages/compression/test/pipes.test.ts @@ -0,0 +1,129 @@ +import { BindenAbortError, type Context } from "binden"; +import { type IServed, dechunk, exchange, serve } from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Compression } from "../index.js"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import zlib from "node:zlib"; + +/** + * Runs `fn` while each `gzip` compressor that `node:zlib` creates counts the + * compressed bytes that it gives (`produced()`), at its output: before the + * compression gets them, so before anything could hold back their writes + */ +async function counting( + fn: (produced: () => number) => Promise, +): Promise { + let produced = 0; + const original = Object.getOwnPropertyDescriptor(zlib, "createGzip"); + const create = original?.value as typeof zlib.createGzip; + Object.defineProperty(zlib, "createGzip", { + ...original, + value: (options?: zlib.ZlibOptions): zlib.Gzip => + create(options).on("data", (chunk: Buffer) => { + produced += chunk.length; + }), + }); + try { + await fn(() => produced); + } finally { + Object.defineProperty(zlib, "createGzip", original as PropertyDescriptor); + } +} + +suite("A pipe into a compressed response", () => { + const servers: IServed[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("whose head Node.js refuses fails its work: one `500`, no crash", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "pipe-failure.ts"), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + deepStrictEqual( + { status, stdout, stderr }, + { + status: 0, + stdout: "500(open) 500(open)\npipeline:ERR_INVALID_CHAR\n", + stderr: "", + }, + ); + }); + + test( + "the deadline cuts a compressed body that has not been sent in full", + { + // Windows takes a whole write while its send buffer has room: the + // response may finish before the client reads it + skip: process.platform === "win32", + }, + async () => { + const body = randomBytes(8 * 1024 * 1024); + // What the request has been aborted by, and what the compressor had + // given by then + const { promise: deadline, resolve } = Promise.withResolvers<{ + readonly reason: unknown; + readonly produced: number; + }>(); + await counting(async (produced) => { + const served = await serve( + { app: { timeout: 100 } }, + new Compression({ format: "gzip" }), + async (context: Context) => { + // The client reads once the request has been aborted (whenever + // a loaded machine runs it), never by a timer of its own that + // may come first + context.signal.addEventListener( + "abort", + () => { + resolve({ + reason: context.signal.reason, + produced: produced(), + }); + }, + { once: true }, + ); + context.response.type("text/plain"); + // Not awaited: the client reads slowly + context.response.send(body).catch(() => null); + await new Promise(() => { + // Abandoned at its deadline + }); + }, + ); + servers.push(served); + const { head, body: raw } = await exchange(served.url, deadline); + const { reason, produced: given } = await deadline; + // Cut by the deadline, not by anything else (e.g. a connection + // closed before the first chunk) + ok(reason instanceof BindenAbortError, String(reason)); + deepStrictEqual(reason.reason, "deadline"); + if (head === "" && raw.length === 0) { + // Cut before anything of it was sent: only when the compressor had + // not given its first chunk by the deadline (e.g. on a loaded + // machine). The client gets nothing, and the connection is closed. + deepStrictEqual(given, 0); + return; + } + ok(head.startsWith("HTTP/1.1 200 "), head); + const { body: compressed, complete } = dechunk(raw); + ok(!complete); + ok( + zlib.gunzipSync(compressed, { + finishFlush: zlib.constants.Z_SYNC_FLUSH, + }).length < body.length, + ); + }); + }, + ); +}); diff --git a/packages/compression/test/process.test.ts b/packages/compression/test/process.test.ts new file mode 100644 index 00000000..42f9d81b --- /dev/null +++ b/packages/compression/test/process.test.ts @@ -0,0 +1,146 @@ +import { deepStrictEqual, ok } from "node:assert"; +import { suite, test } from "node:test"; +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, ...args: string[]): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", fixture), + ...args, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +/** The refusal of a view whose bytes are gone, formatted before the application gets it */ +const lost = "The data is a view of a detached ArrayBuffer (formatted)"; + +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: "", + }); + }); + + test("a refused chunk once the compression has ended the response does not crash the process", () => { + deepStrictEqual(run("ended-chunk.ts"), { + status: 0, + stdout: [ + "/write-end HTTP/1.1 406 Not Acceptable", + "/write-end-object HTTP/1.1 406 Not Acceptable", + "/write-write HTTP/1.1 406 Not Acceptable", + "/head-end HTTP/1.1 406 Not Acceptable", + "/head-end-object HTTP/1.1 406 Not Acceptable", + "/head-write HTTP/1.1 406 Not Acceptable", + "/compressed HTTP/1.1 200 OK", + "", + ].join("\n"), + stderr: "", + }); + }); + + test("a `filter` that detaches the bytes of a view given to send(), or of a chunk of a Readable, inside the write of binden: the send rejects with the TypeError of binden, formatted, and the connection is closed before the head is flushed, compressed or not (H26-1)", () => { + for (const mode of ["send", "chunks"]) { + for (const threshold of ["0", "100000"]) { + deepStrictEqual( + run("detaching-filter.ts", mode, threshold, "detach"), + { status: 0, stdout: `${lost} | \n`, stderr: "" }, + `${mode} ${threshold}`, + ); + deepStrictEqual( + run("detaching-filter.ts", mode, threshold, "keep"), + { + status: 0, + stdout: "resolved | HTTP/1.1 200 HTTP/1.1 200 next\n", + stderr: "", + }, + `${mode} ${threshold}`, + ); + } + } + }); + + for (const mode of ["send", "chunks"]) { + test(`the 406 that replaces a response inside the write of binden drops the bytes (${mode}), whether the \`filter\` has detached them or not, and reports each write (or end) that it discards: the send resolves, the 406 is replied, and the connection is reused, also when a writer of the application ends the response inside that write, or gives its chunk to end() (C27-1, C28-1)`, () => { + for (const detach of ["keep", "detach"]) { + for (const ending of ["", "ending", "end"]) { + deepStrictEqual( + run("detaching-filter.ts", mode, "406", detach, ending), + { + status: 0, + stdout: "resolved | HTTP/1.1 406 HTTP/1.1 200 next\n", + stderr: "", + }, + `${detach} ${ending}`, + ); + } + } + }); + + test(`a writer of the application that ends the response inside the write of binden (${mode}), or gives its chunk to end(), once the compressor has accepted bytes that the \`filter\` has detached (or binden has, for a response sent as it is): the send rejects with the TypeError of binden, formatted, and the connection is closed, the pipelined response never read as the body (C28-1)`, () => { + for (const ending of ["ending", "end"]) { + deepStrictEqual( + run("detaching-filter.ts", mode, "0", "detach", ending), + { status: 0, stdout: `${lost} | \n`, stderr: "" }, + ending, + ); + deepStrictEqual( + run("detaching-filter.ts", mode, "0", "keep", ending), + { + status: 0, + stdout: "resolved | HTTP/1.1 200 HTTP/1.1 200 next\n", + stderr: "", + }, + ending, + ); + const { status, stdout, stderr } = run( + "detaching-filter.ts", + mode, + "100000", + "detach", + ending, + ); + deepStrictEqual({ status, stderr }, { status: 0, stderr: "" }, ending); + // The end of the writer may have flushed the head (Node.js uncorks + // the socket), but never the pipelined response + ok( + [`${lost} | \n`, `${lost} | HTTP/1.1 200\n`].includes(String(stdout)), + `${ending} ${String(stdout)}`, + ); + } + }); + } + + test("a context whose every use throws once run() has returned: the compression reads the request once, its later uses of the response throw to their callers, and the process does not crash", () => { + deepStrictEqual(run("late-context.ts"), { + status: 0, + stdout: 'closed ""\n', + stderr: "", + }); + deepStrictEqual(run("late-context.ts", "request"), { + status: 0, + stdout: "gzip\n", + stderr: "", + }); + }); + + test("a head that Node.js refuses under a decision does not crash the process", () => { + deepStrictEqual(run("refused-head.ts"), { + status: 0, + stdout: "HTTP/1.1 500 Internal Server Error (0 bytes) (19)\n", + stderr: "", + }); + }); +}); diff --git a/packages/compression/test/refused-sends.test.ts b/packages/compression/test/refused-sends.test.ts new file mode 100644 index 00000000..bad87b56 --- /dev/null +++ b/packages/compression/test/refused-sends.test.ts @@ -0,0 +1,164 @@ +import { type AddressInfo, connect } from "node:net"; +import { Binden, type Context, releasedFailure } from "binden"; +import { PassThrough, Readable } from "node:stream"; +import { capture, ioTurn } from "./helpers.js"; +import { suite, test } from "node:test"; +import { Compression } from "../index.js"; +import { deepStrictEqual } from "node:assert/strict"; +import { once } from "node:events"; + +/** + * Writes the requests on one connection to `127.0.0.1`, and reads until the + * reply of `/next` (`next`) has come in full, or the connection closes, + * within 10 s (never judged by silence) + */ +async function exchange( + port: number, + text: string, +): Promise<{ received: string; closed: boolean }> { + const socket = connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + let received = ""; + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + reject(new Error(`No end within 10 s: ${received}`)); + }, 10_000); + socket + .setEncoding("latin1") + .on("data", (chunk: string) => { + received += chunk; + if (received.endsWith("\r\n\r\nnext")) { + resolve(false); + } + }) + .on("error", () => { + // Its close follows + }) + .on("close", () => { + resolve(true); + }); + socket.write(text); + try { + const closed = await promise; + return { received, closed }; + } finally { + clearTimeout(timer); + } + } finally { + socket.destroy(); + } +} + +/** A request that accepts no representation (nothing acceptable: `406`) */ +const refusing = + "GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: identity;q=0, *;q=0\r\n\r\n"; + +suite( + "A send whose first chunk makes the compression reply `406 Not Acceptable`", + () => { + for (const [how, queued, failing, logged] of [ + // Read again once its first chunk is written: it fails within the turn + ["fails at once", false, null, 1], + [ + "fails", + true, + (source: Readable): void => { + source.destroy(new Error("The source has failed")); + }, + 1, + ], + [ + "closes", + true, + (source: Readable): void => { + source.destroy(); + }, + 0, + ], + ] as const) { + test(`then its source ${how}${queued ? ", while the 406 waits for the response before it" : ""}: the 406 and the next response are sent`, async () => { + const captured = capture(); + const outcome = Promise.withResolvers(); + const failed = Promise.withResolvers(); + const app = new Binden({ logger: captured.logger }) + .use(new Compression({ threshold: 0 })) + .use(async (context: Context) => { + const { pathname } = context.url; + if (pathname === "/next") { + await context.send("next"); + return; + } else if (pathname === "/slow") { + await failed.promise; + await context.send("slow"); + return; + } + let first = true; + const source = + failing === null + ? new Readable({ + read(): void { + if (first) { + first = false; + this.push("first"); + } else { + this.destroy(new Error("The source has failed")); + } + }, + }) + : new PassThrough(); + const sent = context.send(source).then( + () => "resolved", + (error: unknown) => error, + ); + if (failing !== null) { + // Its first chunk: the compression replaces the response with + // `406` + (source as PassThrough).write("first"); + await new Promise(setImmediate); + failing(source); + } + await new Promise(setImmediate); + const { statusCode, writableEnded } = context.response; + failed.resolve(null); + outcome.resolve([statusCode, writableEnded, await sent]); + }); + const server = app.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const { port } = server.address() as AddressInfo; + const { received, closed } = await exchange( + port, + `${queued ? "GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n" : ""}${refusing}GET /next HTTP/1.1\r\nHost: localhost\r\n\r\n`, + ); + const settled = await outcome.promise; + // A late error is logged on a later turn + await ioTurn(); + deepStrictEqual( + [ + received.match(/HTTP\/1\.1 \d{3}/gu), + closed, + settled, + captured.records.filter(({ msg }) => msg === releasedFailure) + .length, + ], + [ + [ + ...(queued ? ["HTTP/1.1 200"] : []), + "HTTP/1.1 406", + "HTTP/1.1 200", + ], + false, + [406, true, "resolved"], + logged, + ], + ); + } finally { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + } + }); + } + }, +); diff --git a/packages/compression/test/refused.test.ts b/packages/compression/test/refused.test.ts new file mode 100644 index 00000000..7ef1be46 --- /dev/null +++ b/packages/compression/test/refused.test.ts @@ -0,0 +1,219 @@ +import { type BindenResponse, type Context } from "binden"; +import { + type IServed, + converse, + framed, + gzips, + serve, + text, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Compression } from "../index.js"; +import { Readable } from "node:stream"; +import zlib from "node:zlib"; + +const body = text(4096); +const length = { "content-length": 4096 }; + +/** The headers that a decision changes (as the error handler gets them) */ +const decided = [ + "accept-ranges", + "connection", + "content-encoding", + "content-length", + "etag", + "transfer-encoding", + "vary", +]; +const reply = text(2048); +const invalid = "bad\n"; + +/** + * Replies that Node.js refuses (in `writeHead()`, or once the reply commits + * its headers), by path: `kValidateHead` checks them before the decision + */ +const checked: Partial unknown>> = + { + "/reason": (response) => response.writeHead(201, invalid), + "/header": (response) => response.writeHead(201, { "X-A": invalid }), + "/pairs": (response) => response.writeHead(201, ["X-A"]), + "/end": (response) => { + response.statusMessage = invalid; + response.end(body); + }, + "/write": (response) => { + response.statusMessage = invalid; + response.write(body); + }, + "/flush": async (response) => { + response.statusMessage = invalid; + await response.flush(); + }, + "/send": async (response) => { + response.statusMessage = invalid; + await response.send(body); + }, + // Its dynamic `ETag` chooses `406` first: the error reply does not get it + "/send-406": async (response) => { + response.statusMessage = invalid; + await response.send(body); + }, + "/stream": async (response) => { + response.statusMessage = invalid; + await response.send(Readable.from([body])); + }, + "/file": async (response) => { + response.statusMessage = invalid; + await response.sendFile(import.meta.filename, { + content_type: "text/plain", + validator: '"f"', + }); + }, + }; + +/** + * Replies that Node.js refuses: a status out of range (which `kValidateHead` + * refuses before the decision, as Node.js refuses it), and a `Trailer` of a + * body that is not chunked (of HTTP/1.0), once the decision is taken + */ +const refused: Partial unknown>> = + { + "/status": (response) => { + response.statusCode = 1000; + response.end(body); + }, + "/trailer": (response) => { + response.setHeader("Trailer", "X-T"); + response.writeHead(201); + }, + "/trailer-end": (response) => { + // Changed by the decision, then restored + response.set({ + "Content-Length": body.length, + ETag: '"v"', + Trailer: "X-T", + Vary: "Origin", + }); + response.end(body); + }, + }; + +suite("A head that Node.js refuses", () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("is an error without a compressor, replied with a decision of its own", async () => { + // By path: the error, then the state of the response that the error + // handler gets, and the compressors that the reply has created + const states = new Map(); + await gzips(async (compressors) => { + let before = 0; + served = await serve( + { + etag: true, + app: { + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + const { response } = context; + states.set(context.url.pathname, [ + (error as { code?: unknown }).code, + response.statusCode, + response.headersSent, + Object.fromEntries( + decided + .map((name) => [name, response.getHeader(name)]) + .filter(([, value]) => typeof value !== "undefined"), + ), + compressors.slice(before).map(({ destroyed }) => destroyed), + ]); + response.statusMessage = ""; + response.removeHeader("Trailer"); + // An empty body is never refused + await response + .status(500) + .type("text/plain") + .send(context.url.pathname.endsWith("-406") ? "" : reply); + }, + }, + }, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + before = compressors.length; + response.type("text/plain"); + const { pathname } = context.url; + await (checked[pathname] ?? refused[pathname])?.(response); + }, + ); + const { url } = served; + for (const [path, version, accept] of [ + ...Object.keys(checked).map((name) => [ + name, + "1.1", + name.endsWith("-406") ? "identity;q=0, gzip;q=0" : "gzip", + ]), + ["/status", "1.1", "gzip"], + ["/status", "1.0", "gzip"], + ["/trailer", "1.0", "gzip"], + ["/trailer-end", "1.0", "gzip"], + ] as const) { + const responses = await converse( + url, + `GET ${path} HTTP/${version}\r\nHost: localhost\r\nAccept-Encoding: ${accept}\r\nConnection: close\r\n\r\n`, + ); + const name = `${path} (HTTP/${version})`; + deepStrictEqual(responses.length, 1, name); + const [response] = responses as [(typeof responses)[0]]; + ok(response.head.startsWith("http/1.1 500 "), name); + const coding = /\r\ncontent-encoding: (?\S+)/u.exec( + response.head, + )?.groups?.coding; + const raw = framed(response); + // The reply of the error has the decision of its own body + deepStrictEqual( + typeof coding === "undefined" + ? [null, raw.toString()] + : [coding, zlib.gunzipSync(raw).toString()], + path.endsWith("-406") ? [null, ""] : ["gzip", reply], + name, + ); + } + }); + + deepStrictEqual(Object.fromEntries(states), { + "/reason": ["ERR_INVALID_CHAR", 200, false, {}, []], + "/header": ["ERR_INVALID_CHAR", 200, false, {}, []], + "/pairs": ["ERR_INVALID_ARG_VALUE", 200, false, {}, []], + "/end": ["ERR_INVALID_CHAR", 200, false, {}, []], + "/write": ["ERR_INVALID_CHAR", 200, false, {}, []], + "/flush": ["ERR_INVALID_CHAR", 200, false, {}, []], + // As before the send (H30-2): without its length, its dynamic `ETag`, + // nor the `406` that has replaced its body + "/send": ["ERR_INVALID_CHAR", 200, false, {}, []], + "/send-406": ["ERR_INVALID_CHAR", 200, false, {}, []], + "/stream": ["ERR_INVALID_CHAR", 200, false, {}, []], + // Without the headers of the file nor the framing of its send: a + // sendFile() that fails once it has set them restores the response + "/file": ["ERR_INVALID_CHAR", 200, false, {}, []], + // Refused before the decision: no compressor + "/status": ["ERR_HTTP_INVALID_STATUS_CODE", 1000, false, {}, []], + // Created, then destroyed + "/trailer": ["ERR_HTTP_TRAILER_INVALID", 200, false, {}, [true]], + "/trailer-end": [ + "ERR_HTTP_TRAILER_INVALID", + 200, + false, + { ...length, etag: '"v"', vary: "Origin" }, + [true], + ], + }); + }); +}); diff --git a/packages/compression/test/replaced-loggers.test.ts b/packages/compression/test/replaced-loggers.test.ts new file mode 100644 index 00000000..351a67a5 --- /dev/null +++ b/packages/compression/test/replaced-loggers.test.ts @@ -0,0 +1,98 @@ +import { capture, fetchRaw, serve } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Compression } from "../index.js"; +import type { Context } from "binden"; +import { test } from "node:test"; + +/** An error of the application's own */ +class OwnError extends Error {} + +/** The methods of a logger that the application replaces */ +const methods = [ + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + "child", +] as const; + +test("a filter that fails is logged by the guarded path of binden: the methods and child() of context.log replaced by throwing ones, or a throwing log getter, never run nor crash, and the response is replaced with an empty 500 all the same", async () => { + const captured = capture(); + /** The calls of the methods that the application has replaced */ + const called: string[] = []; + const served = await serve( + { app: { logger: captured.logger } }, + new Compression({ + filter: (context): boolean => { + if (context.url.pathname.endsWith("-boolean")) { + return "true" as unknown as boolean; + } + throw new OwnError("filter"); + }, + }), + async (context: Context) => { + const { pathname } = context.url; + // Mid-request, once the compression is installed + if (pathname.startsWith("/getter")) { + Object.defineProperty(context, "log", { + configurable: true, + get(): never { + throw new OwnError("log"); + }, + }); + } else { + for (const name of methods) { + Reflect.set(context.log, name, (): never => { + called.push(name); + throw new OwnError(name); + }); + } + } + const { response } = context; + response.setHeader("Content-Type", "text/plain"); + await new Promise((resolve) => { + response.end("body", resolve); + }); + }, + ); + try { + for (const path of [ + "/methods", + "/methods-boolean", + "/getter", + "/getter-boolean", + ]) { + const response = await fetchRaw(served.url, { + path, + headers: { "accept-encoding": "gzip" }, + }); + deepStrictEqual( + [response.status, response.headers["content-length"]], + [500, "0"], + path, + ); + ok(!("content-type" in response.headers), path); + deepStrictEqual(response.raw.length, 0, path); + } + } finally { + await served.close(); + } + deepStrictEqual(called, []); + // Written by the logger of binden itself, whatever its methods are; the + // records of a context whose `log` throws are dropped + await captured.waitFor(2); + deepStrictEqual( + captured.records.filter(({ msg }) => + String(msg).startsWith("The `filter` of the compression"), + ), + [ + { level: 50, msg: "The `filter` of the compression has failed" }, + { + level: 50, + msg: "The `filter` of the compression has not returned a boolean", + }, + ], + ); +}); diff --git a/packages/compression/test/representation.test.ts b/packages/compression/test/representation.test.ts new file mode 100644 index 00000000..5bee1c25 --- /dev/null +++ b/packages/compression/test/representation.test.ts @@ -0,0 +1,232 @@ +/* eslint-disable init-declarations */ +import { Compression, isCompressible, skipCompression } from "../index.js"; +import { type ContentType, type Context, type IBindenOptions } from "binden"; +import { + type IServed, + fetchRaw, + gzips, + none, + pollUntil, + serve, + text, +} from "./helpers.js"; +import { PassThrough, type Readable } from "node:stream"; +import { after, afterEach, before, mock, suite, test } from "node:test"; +import fsp, { + type FileHandle, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { deepStrictEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; + +/** A promise with its `resolve()` */ +function gate(): { promise: Promise; open(): void } { + const { promise, resolve } = Promise.withResolvers(); + return { promise, open: resolve as () => void }; +} + +/** + * The streams of the files that `open()` opens read once `reading` resolves, + * or fail before their first chunk with `reading` (an error): the streams + * created so far + */ +function delayReads(reading: Error | Promise): Readable[] { + const streams: Readable[] = []; + const { open } = fsp; + mock.method( + fsp, + "open", + async (...args: Parameters): Promise => { + const handle = await open(...args); + const create = handle.createReadStream.bind(handle); + handle.createReadStream = (( + ...options: Parameters + ) => { + const stream = create(...options); + const proxy = new PassThrough(); + streams.push(proxy); + proxy.once("close", () => { + stream.destroy(); + }); + if (reading instanceof Error) { + process.nextTick(() => { + proxy.destroy(reading); + }); + } else { + reading + .then(() => { + stream.pipe(proxy); + }) + .catch(() => null); + } + return proxy; + }) as unknown as FileHandle["createReadStream"]; + return handle; + }, + ); + syncBuiltinESMExports(); + return streams; +} + +const gzip = { "Accept-Encoding": "gzip" }; + +suite("The representation chosen before the commit", () => { + let tmp: string; + let file: string; + const content = text(4096); + const reply = text(2048); + const validator = `"v1"`; + const servers: IServed[] = []; + + const start = async ( + app: IBindenOptions, + compression: Compression, + handler: (context: Context) => Promise, + ): Promise => { + const served = await serve({ app }, compression, handler); + servers.push(served); + return served.url; + }; + + before(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), "binden-compression-"))); + file = join(tmp, "file.txt"); + await writeFile(file, content); + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + afterEach(async () => { + mock.restoreAll(); + syncBuiltinESMExports(); + await Promise.all(servers.splice(0).map(async (served) => served.close())); + }); + + test("`sendFile()`: the choice sends no header and creates no compressor, the commit applies it", async () => { + const reading = gate(); + const streams = delayReads(reading.promise); + const states: unknown[] = []; + let filtered = 0; + const compression = new Compression({ + filter: (_context: Context, type: ContentType | null): boolean => { + filtered += 1; + return isCompressible(type); + }, + }); + const created = await gzips(async (compressors) => { + const url = await start({}, compression, async (context: Context) => { + const { response } = context; + const sent = response.sendFile(file, { validator }); + // Chosen, and its stream created + await pollUntil(() => streams.length > 0); + states.push( + response.headersSent, + response.getHeader("Content-Encoding"), + response.getHeader("Vary"), + response.getHeader("ETag"), + filtered, + compressors.length, + ); + reading.open(); + await sent; + }); + const received = await fetchRaw(url, { headers: gzip }); + deepStrictEqual( + [ + received.status, + received.headers["content-encoding"], + received.headers.vary, + received.headers.etag, + received.body, + ], + [200, "gzip", "Accept-Encoding", `W/${validator}`, content], + ); + }); + deepStrictEqual(states, [false, none, none, validator, 1, 0]); + // Not decided again by the commit + deepStrictEqual([filtered, created.length], [1, 1]); + }); + + test("`sendFile()`: a refused representation does not read the file", async () => { + const streams = delayReads(Promise.resolve()); + const url = await start({}, new Compression(), async (context: Context) => { + await context.response.sendFile(file, { validator }); + }); + const refused = await fetchRaw(url, { + headers: { "Accept-Encoding": "identity;q=0" }, + }); + deepStrictEqual( + [refused.status, refused.headers["content-length"], streams.length], + [406, "0", 0], + ); + const sent = await fetchRaw(url, { headers: gzip }); + deepStrictEqual( + [sent.status, sent.body, streams.length], + [200, content, 1], + ); + }); + + test("a deadline before the first chunk: the reply of the error handler is not the chosen representation", async () => { + delayReads(gate().promise); + const url = await start( + { + timeout: 50, + error_handler: async (context: Context): Promise => { + skipCompression(context.response); + context.response.status(503); + await context.text(reply); + }, + }, + new Compression(), + async (context: Context) => { + await context.response.sendFile(file, { validator }); + }, + ); + const received = await fetchRaw(url, { headers: gzip }); + deepStrictEqual( + [ + received.status, + received.headers["content-encoding"], + received.headers.etag, + received.raw.toString(), + ], + [503, none, none, reply], + ); + }); + + test("a stream that fails before its first chunk: the error reply is not the chosen representation", async () => { + delayReads(new Error("Failed")); + const failures: unknown[] = []; + const url = await start( + { + error_handler: async (context: Context, error: unknown) => { + failures.push((error as Error).message); + skipCompression(context.response); + context.response.status(500); + await context.text(reply); + }, + }, + new Compression(), + async (context: Context) => { + await context.response.sendFile(file, { validator }); + }, + ); + const received = await fetchRaw(url, { headers: gzip }); + deepStrictEqual( + [ + received.status, + received.headers["content-encoding"], + received.raw.toString(), + ], + [500, none, reply], + ); + deepStrictEqual(failures, ["Failed"]); + }); +}); diff --git a/packages/compression/test/rollback.test.ts b/packages/compression/test/rollback.test.ts new file mode 100644 index 00000000..d543090e --- /dev/null +++ b/packages/compression/test/rollback.test.ts @@ -0,0 +1,560 @@ +import { type BindenResponse, type Context } from "binden"; +import { + type IRaw, + type IServed, + converse, + framed, + gzips, + none, + serve, + text, + within, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { Compression } from "../index.js"; +import type { Socket } from "node:net"; +import { deepStrictEqual } from "node:assert/strict"; +import { isDeepStrictEqual } from "node:util"; +import zlib from "node:zlib"; + +/** + * A deep copy of the state of a response: its own properties (string and + * symbol keys, with the contents of the header map and of the listener + * table, and the other objects by identity), and the values of its accessors + * (e.g. the storage of `shouldKeepAlive` and of the status) + */ +function snapshot(response: BindenResponse): Map { + const copy = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(copy); + } else if ( + typeof value === "object" && + value !== null && + Object.getPrototypeOf(value) === null + ) { + return Reflect.ownKeys(value).map((key) => [ + key, + copy(Reflect.get(value, key)), + ]); + } + return value; + }; + const state = new Map(); + for (const key of Reflect.ownKeys(response)) { + state.set(key, copy(Reflect.get(response, key))); + } + for (const name of [ + "chunkedEncoding", + "headersSent", + "sendDate", + "shouldKeepAlive", + "started", + "statusCode", + "statusMessage", + "writableEnded", + ]) { + state.set(`get ${name}`, Reflect.get(response, name)); + } + state.set("getHeaders()", Object.entries(response.getHeaders())); + return state; +} + +/** The keys whose values differ between two snapshots (see `snapshot()`) */ +function changed( + before: Map, + after: Map, +): unknown[] { + const keys = new Set([...before.keys(), ...after.keys()]); + return [...keys] + .filter((key) => !isDeepStrictEqual(before.get(key), after.get(key))) + .map(String); +} + +const big = text(4096); +const page = text(4400); + +/** + * Heads that Node.js refuses once the decision is taken, by path: what is + * set before, then what commits the head + */ +const attempts: Record< + string, + readonly [ + setup: (response: BindenResponse) => void, + commit: (response: BindenResponse) => void, + ] +> = { + // As it is (below the `threshold`), of a length: by the `end()` of Node.js + "/as-is-end": [ + (response): void => { + response.set({ "Content-Length": 4, Trailer: "X-T" }); + }, + (response): void => { + response.end("tiny"); + }, + ], + // As it is, by the first `write()` + "/as-is-write": [ + (response): void => { + response.set({ "Content-Length": 4, Trailer: "X-T" }); + }, + (response): void => { + response.write("tiny"); + }, + ], + // As it is, with the byte count of `strictContentLength`, which Node.js + // counts before it commits the head + "/strict-end": [ + (response): void => { + response.strictContentLength = true; + response.set({ "Content-Length": 4, Trailer: "X-T" }); + }, + (response): void => { + response.end("tiny"); + }, + ], + "/strict-write": [ + (response): void => { + response.strictContentLength = true; + response.set({ "Content-Length": 4, Trailer: "X-T" }); + }, + (response): void => { + response.write("tiny"); + }, + ], + // As it is, not chunked for HTTP/1.0 + "/as-is-http10": [ + (response): void => { + response.setHeader("Trailer", "X-T"); + }, + (response): void => { + response.end("tiny"); + }, + ], + // The headers of the connection (and a `Date`), set before: Node.js + // applies them first + "/keep-alive": [ + (response): void => { + response.set({ + Connection: "keep-alive", + "Keep-Alive": "timeout=5", + "Content-Length": 4, + Date: new Date(0).toUTCString(), + Trailer: "X-T", + }); + }, + (response): void => { + response.end("tiny"); + }, + ], + // A `304` that stands for a compressed `200`: chunks, which Node.js does + // not send (nor keeps the connection) for it + "/304": [ + (response): void => { + response.status(304).set({ + "Transfer-Encoding": "chunked", + Trailer: "X-T", + }); + }, + (response): void => { + response.end(); + }, + ], + // Chunks of a `204`, set by the application + "/204": [ + (response): void => { + response.status(204).setHeader("Trailer", "X-T"); + response.chunkedEncoding = true; + }, + (response): void => { + response.end(); + }, + ], + // Compressed: for HTTP/1.0, delimited by closing the connection. The + // decision changes headers set before (removed, or a weak `ETag`). + "/compressed-end": [ + (response): void => { + response.set({ + "Content-Length": big.length, + ETag: '"v"', + "Accept-Ranges": "bytes", + "Repr-Digest": "sha-256=:AAAA:", + Vary: "Origin", + Trailer: "X-T", + "X-After": "set", + }); + }, + (response): void => { + response.end(big); + }, + ], + "/compressed-write-head": [ + (response): void => { + response.setHeader("X-Before", "set"); + }, + (response): void => { + response.writeHead(200, { Trailer: "X-T" }); + }, + ], +}; + +/** The scenarios: the path, the version of HTTP, and its `Connection` */ +const scenarios = [ + ["/as-is-end", "1.1", ""], + ["/as-is-end", "1.0", "keep-alive"], + ["/as-is-write", "1.1", ""], + ["/as-is-write", "1.0", "keep-alive"], + ["/strict-end", "1.1", ""], + ["/strict-write", "1.1", ""], + ["/as-is-http10", "1.0", "keep-alive"], + ["/as-is-http10", "1.0", ""], + ["/keep-alive", "1.1", ""], + ["/304", "1.1", ""], + ["/304", "1.0", "keep-alive"], + ["/204", "1.1", ""], + ["/keep-alive", "1.0", "keep-alive"], + ["/compressed-end", "1.0", "keep-alive"], + ["/compressed-write-head", "1.0", "keep-alive"], + ["/compressed-write-head", "1.0", ""], +] as const; + +/** A response without its `Date` (the only header that may differ) */ +function dated({ head, body }: IRaw): [string, Buffer] { + return [head.replace(/\r\ndate: [^\r]*/u, ""), body]; +} + +suite("A refused head leaves nothing of the attempt", () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("the state of the response, its listeners, and the reply that follows", async () => { + // By scenario: the error, and what the attempt has changed + const states: Record = {}; + const compression = new Compression({ format: "gzip" }); + await gzips(async (compressors) => { + served = await serve({}, compression, async (context: Context) => { + const { response } = context; + const { pathname, searchParams } = context.url; + const [setup, commit] = attempts[pathname]; + response.type("text/plain"); + setup(response); + const scenario = searchParams.get("attempt"); + if (scenario !== null) { + const before = snapshot(response); + const listeners = response.listeners("close"); + const count = compressors.length; + let code: unknown = null; + try { + commit(response); + } catch (error: unknown) { + ({ code } = error as { code?: unknown }); + } + states[scenario] = [ + code, + changed(before, snapshot(response)), + isDeepStrictEqual(response.listeners("close"), listeners), + compressors.slice(count).map(({ destroyed }) => destroyed), + ]; + } + // The reply that follows, negotiated anew (compressed but for a + // client of HTTP/1.0, whose connection it would close) + for (const name of ["Content-Length", "Trailer", "Transfer-Encoding"]) { + response.removeHeader(name); + } + response.chunkedEncoding = false; + await response + .status(200) + .send(context.request.httpVersion === "1.0" ? "ok" : page); + }); + const { url } = served; + for (const [path, version, connection] of scenarios) { + const name = `${path} (HTTP/${version}${connection ? `, ${connection}` : ""})`; + // A pipelined request when the connection is kept alive (otherwise + // refused by Node.js, as data after the end of the connection) + const kept = connection === "keep-alive" || version === "1.1"; + const next = kept + ? "GET /as-is-end HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + : ""; + // With the attempt and without it + const [replies, reference] = await Promise.all( + [`?attempt=${encodeURIComponent(name)}`, ""].map(async (query) => + converse( + url, + `GET ${path}${query} HTTP/${version}\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n${connection ? `Connection: ${connection}\r\n` : ""}\r\n${next}`, + ), + ), + ); + deepStrictEqual(replies.map(dated), reference.map(dated), name); + // The pipelined request is answered + deepStrictEqual(replies.length, kept ? 2 : 1, name); + const [first] = replies as [IRaw]; + const coding = /\r\ncontent-encoding: (?\S+)/u.exec(first.head) + ?.groups?.coding; + deepStrictEqual( + [ + coding, + (coding === "gzip" + ? zlib.gunzipSync(framed(first)) + : framed(first) + ).toString(), + ], + version === "1.0" ? [none, "ok"] : ["gzip", page], + name, + ); + } + }); + + const refused = (created: boolean): unknown[] => [ + "ERR_HTTP_TRAILER_INVALID", + [], + true, + created ? [true] : [], + ]; + deepStrictEqual(states, { + "/as-is-end (HTTP/1.1)": refused(false), + "/as-is-end (HTTP/1.0, keep-alive)": refused(false), + "/as-is-write (HTTP/1.1)": refused(false), + "/as-is-write (HTTP/1.0, keep-alive)": refused(false), + "/strict-end (HTTP/1.1)": refused(false), + "/strict-write (HTTP/1.1)": refused(false), + "/as-is-http10 (HTTP/1.0, keep-alive)": refused(false), + "/as-is-http10 (HTTP/1.0)": refused(false), + "/keep-alive (HTTP/1.1)": refused(false), + "/304 (HTTP/1.1)": refused(false), + "/304 (HTTP/1.0, keep-alive)": refused(false), + "/204 (HTTP/1.1)": refused(false), + "/keep-alive (HTTP/1.0, keep-alive)": refused(false), + "/compressed-end (HTTP/1.0, keep-alive)": refused(true), + "/compressed-write-head (HTTP/1.0, keep-alive)": refused(true), + "/compressed-write-head (HTTP/1.0)": refused(true), + }); + }); + + test("the byte count of `strictContentLength`: the reply that follows is framed by its own length", async () => { + const codes: unknown[] = []; + served = await serve( + {}, + new Compression({ format: "gzip" }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + if (context.url.pathname === "/next") { + response.end("next"); + return; + } + // Sent as it is (below the `threshold`) + response.strictContentLength = true; + response.set({ "Content-Length": 4, Trailer: "X-T" }); + try { + if (context.url.pathname === "/write") { + response.write("body"); + } else { + response.end("body"); + } + } catch (error: unknown) { + codes.push((error as { code?: unknown }).code); + } + // The same body, which Node.js counts once + response.removeHeader("Trailer"); + response.end("body"); + }, + ); + for (const path of ["/end", "/write"]) { + const replies = await converse( + served.url, + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, + ); + const [reply, next] = replies as [IRaw, IRaw]; + + deepStrictEqual( + [ + replies.length, + reply.head.split("\r\n")[0], + `${reply.head}\r\n`.includes("\r\ncontent-length: 4\r\n"), + framed(reply).toString(), + framed(next).toString(), + ], + [2, "http/1.1 200 ok", true, "body", "next"], + path, + ); + } + deepStrictEqual(codes, [ + "ERR_HTTP_TRAILER_INVALID", + "ERR_HTTP_TRAILER_INVALID", + ]); + }); + + test("no observer of the compressor that it destroys remains", async () => { + const again: number[] = []; + await gzips(async (compressors) => { + served = await serve( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + response.type("text/plain"); + const count = compressors.length; + try { + // Refused for HTTP/1.0 once the compressor is created + response.writeHead(200, { Trailer: "X-T" }); + } catch { + // Rolled back + } + const created = compressors.slice(count); + deepStrictEqual( + created.map(({ destroyed }) => destroyed), + [true], + ); + for (const [index, compressor] of created.entries()) { + const destroy = compressor.destroy.bind(compressor); + compressor.destroy = (error?: Error): typeof compressor => { + again.push(index); + return destroy(error); + }; + } + await response.send("ok"); + }, + ); + const [reply] = (await converse( + served.url, + "GET / HTTP/1.0\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n", + )) as [IRaw]; + deepStrictEqual( + [reply.head.split("\r\n")[0], framed(reply).toString()], + ["http/1.1 200 ok", "ok"], + ); + }); + // The real close of the response does not destroy it again + deepStrictEqual(again, []); + }); + + test("the error reply negotiates its own representation (the reproduction of the review)", async () => { + const seen: unknown[] = []; + served = await serve( + { + app: { + error_handler: async ( + context: Context, + error: unknown, + ): Promise => { + const { response } = context; + seen.push((error as { code?: unknown }).code, { + ...response.getHeaders(), + }); + response.removeHeader("Trailer"); + await response.status(500).text(page); + }, + }, + }, + new Compression(), + async (context: Context) => { + const { response } = context; + switch (context.url.pathname) { + case "/small": + // Below the `threshold`: sent as it is, by its `end()` + response.setHeader("Trailer", "X-T"); + await context.text("tiny"); + break; + case "/write": + response.set({ + "Content-Type": "text/plain; charset=utf-8", + "Content-Length": 4, + Trailer: "X-T", + }); + response.write("tiny"); + break; + default: + await context.text("next"); + } + }, + ); + for (const path of ["/small", "/write"]) { + seen.length = 0; + const replies = await converse( + served.url, + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\nGET /next HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`, + ); + const [error, next] = replies as [IRaw, IRaw]; + deepStrictEqual( + [ + replies.length, + error.head.split("\r\n")[0], + error.head.includes("\r\nvary: accept-encoding\r\n"), + error.head.includes("\r\ncontent-encoding: gzip\r\n"), + zlib.gunzipSync(framed(error)).toString() === page, + framed(next).toString(), + seen, + ], + [ + 2, + "http/1.1 500 internal server error", + true, + true, + true, + "next", + [ + "ERR_HTTP_TRAILER_INVALID", + // A refused send of a value leaves the response as it was + // before it (H30-2), a refused write as it was before the write + path === "/small" + ? { trailer: "X-T" } + : { + trailer: "X-T", + "content-type": "text/plain; charset=utf-8", + "content-length": 4, + }, + ], + ], + path, + ); + } + }); + + test("a decision whose head is sent stands, when Node.js throws afterwards", async () => { + const { promise: thrown, resolve } = Promise.withResolvers(); + served = await serve( + {}, + new Compression({ format: "gzip" }), + (context: Context) => { + const { response } = context; + context.done = true; + response.type("text/plain"); + // Node.js corks the socket once it has committed the head of a write + // (binden checks the chunk and its encoding before, so that no + // argument makes Node.js throw after the head any more) + (response.socket as Socket).cork = (): void => { + throw Object.assign(new Error("After the head"), { + code: "E_AFTER_HEAD", + }); + }; + try { + // As it is (without `Accept-Encoding`) + response.write("x"); + } catch (error: unknown) { + resolve([ + (error as { code?: unknown }).code, + response.headersSent, + response.getHeader("Vary"), + ]); + } + response.destroy(); + }, + ); + await converse( + served.url, + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + + deepStrictEqual(await within(thrown), [ + "E_AFTER_HEAD", + true, + "Accept-Encoding", + ]); + }); +}); diff --git a/packages/compression/test/setup.ts b/packages/compression/test/setup.ts new file mode 100644 index 00000000..60e04105 --- /dev/null +++ b/packages/compression/test/setup.ts @@ -0,0 +1,6 @@ +import { Agent, setGlobalDispatcher } from "undici"; + +// 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/streaming.test.ts b/packages/compression/test/streaming.test.ts new file mode 100644 index 00000000..336d06c6 --- /dev/null +++ b/packages/compression/test/streaming.test.ts @@ -0,0 +1,429 @@ +import { Agent, type IncomingMessage, request } from "node:http"; +import { BindenError, type Context } from "binden"; +import { Compression, type ICompressionOptions } from "../index.js"; +import { + type IServed, + decode, + fetchRaw, + gzips, + sameBytes, + serve, + sleep, + text, + within, +} from "./helpers.js"; +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"; + +const gzip = { "Accept-Encoding": "gzip" }; + +/** Sends a request that accepts `gzip`, and resolves with its response (not read) */ +async function respond(url: string): Promise { + const sent = request(url, { + agent: false, + headers: gzip, + signal: AbortSignal.timeout(10_000), + }).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("a compressed reply keeps its connection alive (chunked)", async () => { + const body = text(4096); + const url = await start((context: Context) => { + const { pathname } = context.url; + if (pathname === "/json") { + return context.json({ body }); + } else if (pathname === "/error") { + // The reply of the default error handler + throw new BindenError(400, { expose: true, json: { body } }); + } + return context.send(body); + }); + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + try { + const outcomes: unknown[] = []; + for (const path of ["/send", "/json", "/error", "/send"]) { + const sent = request(new URL(path, url), { + agent, + headers: gzip, + signal: AbortSignal.timeout(10_000), + }).end(); + const [response] = (await within(once(sent, "response"))) as [ + IncomingMessage, + ]; + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + await within(once(response, "end")); + const { headers } = response; + const decoded = decode( + headers["content-encoding"], + Buffer.concat(chunks), + ); + outcomes.push([ + path, + response.statusCode, + headers["transfer-encoding"], + headers.connection, + decoded.toString().includes(body), + sent.reusedSocket, + ]); + } + deepStrictEqual(outcomes, [ + ["/send", 200, "chunked", "keep-alive", true, false], + ["/json", 200, "chunked", "keep-alive", true, true], + ["/error", 400, "chunked", "keep-alive", true, true], + ["/send", 200, "chunked", "keep-alive", true, true], + ]); + } finally { + agent.destroy(); + } + }); + + 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 gzips(async () => { + 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, { headers: gzip }); + 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( + "a slow client pauses the compressor, whose `drain` the application cannot remove", + { timeout: 15_000 }, + async () => { + const expected = randomBytes(4 * 1024 * 1024); + const url = await start(async (context) => { + context.done = true; + const { response } = context; + // An application that removes the `drain` listeners it believes are + // its own: the compressor resumes all the same + const timer = setInterval(() => { + response.removeAllListeners("drain"); + }, 1); + // Also when the reply never finishes: once the connection closes + response.once("close", () => { + clearInterval(timer); + }); + try { + await new Promise((resolve) => { + response.end(expected, resolve); + }); + } finally { + clearInterval(timer); + } + }); + + const response = await respond(url); + response.pause(); + await sleep(100); + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)).resume(); + await within(once(response, "end")); + sameBytes(decode("gzip", Buffer.concat(chunks)), expected); + }, + ); + + 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, { headers: gzip }); + + 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, { headers: gzip }); + + deepStrictEqual(response.body, `first ${text(2048)}`); + deepStrictEqual(states, [false, true, true]); + deepStrictEqual(await within(code), "ERR_STREAM_ALREADY_FINISHED"); + }); + + test("an `end()` that throws does not end a compressed response", async () => { + const states: unknown[] = []; + const url = await start((context) => { + const { response } = context; + context.done = true; + response.write(text(2048)); + for (const args of [[{}], ["x", "unknown"]]) { + try { + Reflect.apply(response.end.bind(response), response, [ + ...args, + (): void => { + states.push("called"); + }, + ]); + } catch (error: unknown) { + states.push((error as { code?: unknown }).code); + } + states.push(response.writableEnded); + } + // Ended now, with its chunk + response.end(" end"); + }); + const response = await fetchRaw(url, { headers: gzip }); + + deepStrictEqual(response.body, `${text(2048)} end`); + deepStrictEqual(states, [ + "ERR_INVALID_ARG_TYPE", + false, + "ERR_UNKNOWN_ENCODING", + false, + ]); + }); + + 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, { headers: gzip }); + + 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, { headers: gzip }); + + 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, { headers: gzip })); + }); + + 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, { headers: gzip }); + + 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(); + + // With the error of its write that the compressor has not taken, as + // without a compression (which gets the error of the socket) + deepStrictEqual(await within(settled), "ERR_STREAM_DESTROYED"); + }, + ); + + test("a failure of the compressor closes the connection", async () => { + await gzips(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, { headers: gzip }), { code: "ECONNRESET" }); + }); + }); +}); diff --git a/packages/compression/test/strict.test.ts b/packages/compression/test/strict.test.ts new file mode 100644 index 00000000..10512865 --- /dev/null +++ b/packages/compression/test/strict.test.ts @@ -0,0 +1,374 @@ +import { type AddressInfo, connect } from "node:net"; +import { + type ICaptured, + type IServed, + capture, + ioTurn, + pollUntil, + serve, + sleep, + within, +} from "./helpers.js"; +import { + type IncomingMessage, + type Server, + type ServerResponse, + createServer, +} from "node:http"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects } from "node:assert/strict"; +import { Compression } from "../index.js"; +import type { Context } from "binden"; +import { once } from "node:events"; + +/** What a client has read of a connection until the server closed it (or until it was idle) */ +interface IConversation { + readonly data: string; + readonly closed: boolean; +} + +/** + * Writes requests on one connection, and reads every byte until the server + * closes it, or until nothing comes for `idle` milliseconds (judged after a + * turn of the pending I/O, see `ioTurn()`: after a stall of the process, its + * timer runs before the data, or the close, that has come meanwhile is seen; + * once the end of the connection has been read, its `close` itself decides) + */ +async function talk( + port: number, + requests: string, + idle = 300, +): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + let data = ""; + const { promise: quiet, resolve } = Promise.withResolvers(); + const expire = (): void => { + const seen = data.length; + const judge = (): void => { + // Else the data that has come re-armed the timer, or the end of the + // connection has been read: its `close`, which follows, decides + if (data.length === seen && !socket.readableEnded) { + resolve(false); + } + }; + ioTurn().then(judge, judge); + }; + let timer = setTimeout(expire, idle); + socket.setEncoding("latin1").on("data", (chunk: string) => { + data += chunk; + clearTimeout(timer); + timer = setTimeout(expire, idle); + }); + socket.on("error", () => { + // E.g. reset by the server: what it has sent is read + }); + const closed = once(socket, "close").then(() => true); + socket.write(requests); + try { + const ended = await within(Promise.race([closed, quiet])); + return { data: data.replaceAll(/\r\nDate: [^\r]*/gu, ""), closed: ended }; + } finally { + clearTimeout(timer); + socket.destroy(); + } +} + +/** A response (with an injected one after it) of a UTF-8 body framed by its length in characters */ +const injected = "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nINJECTED"; + +/** + * Writes that Node.js refuses for `strictContentLength` once it has committed + * the head (a `Content-Length` of 2, unless the case sets its own): the + * body is below the `threshold`, and sent as it is + */ +const mismatches: Record void> = { + "end() of a longer body": (response) => { + response.end("abc"); + }, + "end() of a shorter body": (response) => { + response.end("a"); + }, + "end() of a longer body in hex": (response) => { + response.end("616263", "hex"); + }, + "write() of the body, then end() of more": (response) => { + response.write("ab"); + response.end(injected); + }, + "write() of a shorter body, then end()": (response) => { + response.write("a"); + response.end(); + }, + "a second write() past the length": (response) => { + response.write("ab"); + response.write("c"); + }, + "end() of a UTF-8 body framed by its length in characters": (response) => { + // Every `€` is two bytes more than a character: the excess is the + // injected response + const body = `${"€".repeat(injected.length / 2)}${injected}`; + response.setHeader("Content-Length", body.length); + response.end(body); + }, +}; + +const pipelined = + "GET /mismatch HTTP/1.1\r\nHost: a\r\nAccept-Encoding: gzip\r\n\r\n" + + "GET /next HTTP/1.1\r\nHost: a\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n"; + +/** Runs a mismatch on a strict response of `Content-Length: 2` */ +function mismatch(name: string, response: ServerResponse): void { + response.strictContentLength = true; + response.setHeader("Content-Type", "text/plain"); + response.setHeader("Content-Length", 2); + mismatches[name](response); +} + +/** A server of Node.js whose handler destroys the response that throws */ +function native(name: string): Server { + return createServer((request: IncomingMessage, response: ServerResponse) => { + if (request.url === "/next") { + response.end("next"); + return; + } + try { + mismatch(name, response); + } catch { + response.destroy(); + } + }); +} + +/** Listens on a free port */ +async function listen(server: Server): Promise { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + server, + url: `http://127.0.0.1:${port}`, + close: async (): Promise => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }, + }; +} + +/** The port of a listening server */ +function portOf({ url }: IServed): number { + return Number(new URL(url).port); +} + +suite( + "A body sent as it is that Node.js refuses once the head is committed", + () => { + const servers: IServed[] = []; + + afterEach(async () => { + await Promise.all( + servers.splice(0).map(async (served) => served.close()), + ); + }); + + /** An application whose middleware lets the error out, through a compression */ + const application = async ( + name: string, + outcomes: unknown[], + captured: ICaptured, + ids: unknown[] = [], + ): Promise => { + const served = await serve( + { app: { logger: captured.logger, access_log: true } }, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + context.done = true; + ids.push(context.id); + context.onCleanup(() => { + outcomes.push(context.signal.aborted, response.writableFinished); + }); + try { + mismatch(name, response); + } catch (error: unknown) { + // Destroyed before the error is thrown + outcomes.push( + (error as { code?: unknown }).code, + response.destroyed, + ); + throw error; + } + }, + ); + servers.push(served); + return served; + }; + + for (const name of Object.keys(mismatches)) { + test(`sends nothing more and closes the connection, as Node.js: ${name}`, async () => { + const outcomes: unknown[] = []; + const ids: unknown[] = []; + const captured = capture(); + const natively = await listen(native(name)); + servers.push(natively); + const served = await application(name, outcomes, captured, ids); + const [expected, actual] = [ + await talk(portOf(natively), pipelined), + await talk(portOf(served), pipelined), + ]; + + // Nothing (neither the head, nor the chunk, nor an injected + // response): also not by the cork that Node.js releases on the next + // tick + deepStrictEqual(actual, expected); + deepStrictEqual(actual, { data: "", closed: true }); + // Its access log once it has closed: aborted + const completed = (): Record[] => + captured.entries.filter( + ({ msg, id }) => + msg === "The request has completed" && id === ids[0], + ); + await pollUntil(() => completed().length > 0).catch(() => null); + deepStrictEqual(outcomes, [ + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + true, + true, + false, + ]); + deepStrictEqual( + completed().map(({ status, aborted }) => [status, aborted]), + [[200, true]], + ); + }); + } + + test("by fetch(): no response, as from Node.js", async () => { + const name = "end() of a UTF-8 body framed by its length in characters"; + const natively = await listen(native(name)); + servers.push(natively); + const served = await application(name, [], capture()); + for (const { url } of [natively, served]) { + await rejects( + fetch(`${url}/mismatch`, { + headers: { "Accept-Encoding": "gzip" }, + signal: AbortSignal.timeout(10_000), + }), + (error: unknown) => + error instanceof TypeError && error.message === "fetch failed", + ); + } + }); + + test("once a write has been flushed: closes the connection after exactly what has been sent", async () => { + const served = await serve( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + response.strictContentLength = true; + response.setHeader("Content-Length", 5); + response.write("ab"); + await response.flush(); + // Past the length: Node.js refuses it before it writes it + response.write("abcd"); + }, + ); + servers.push(served); + const { data, closed } = await talk(portOf(served), pipelined); + + ok(data.startsWith("HTTP/1.1 200 OK\r\n"), data); + ok(data.includes("\r\nContent-Length: 5\r\n"), data); + ok(!data.includes("Content-Encoding"), data); + deepStrictEqual( + [data.slice(data.indexOf("\r\n\r\n") + 4), closed], + ["ab", true], + ); + }); + + test("a first write() past the length (which Node.js does not check) throws before Node.js gets it: the error is replied, nothing injected", async () => { + const outcomes: unknown[] = []; + const served = await serve( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + response.strictContentLength = true; + response.setHeader("Content-Length", 2); + try { + response.write(`ab${injected}`); + } catch (error: unknown) { + outcomes.push( + (error as { code?: unknown }).code, + response.headersSent, + response.destroyed, + ); + throw error; + } + }, + ); + servers.push(served); + const { data, closed } = await talk(portOf(served), pipelined); + + ok(!data.includes("INJECTED"), data); + ok( + data.startsWith("HTTP/1.1 500 Internal Server Error\r\n") && + data.endsWith("\r\n\r\nnext"), + data, + ); + deepStrictEqual( + [closed, outcomes], + [true, ["ERR_HTTP_CONTENT_LENGTH_MISMATCH", false, false]], + ); + }); + + test("of a response that waits for the one before it sends nothing of it once its socket comes", async () => { + const sockets: unknown[] = []; + const served = await serve( + {}, + new Compression({ format: "gzip" }), + async (context: Context) => { + const { response } = context; + if (context.url.pathname === "/first") { + await sleep(50); + await context.text("first"); + return; + } else if (context.url.pathname === "/next") { + await context.text("next"); + return; + } + // Without a socket yet: its head and its chunk are queued + sockets.push(response.socket); + mismatch("end() of a longer body", response); + }, + ); + servers.push(served); + const { data, closed } = await talk( + portOf(served), + `GET /first HTTP/1.1\r\nHost: a\r\nAccept-Encoding: gzip\r\n\r\n${pipelined}`, + ); + + deepStrictEqual( + [ + data.split("\r\n")[0], + data.slice(data.indexOf("\r\n\r\n") + 4), + closed, + ], + ["HTTP/1.1 200 OK", "first", true], + ); + deepStrictEqual(sockets, [null]); + }); + }, +); diff --git a/packages/compression/test/unwound-files.test.ts b/packages/compression/test/unwound-files.test.ts new file mode 100644 index 00000000..e4a3c695 --- /dev/null +++ b/packages/compression/test/unwound-files.test.ts @@ -0,0 +1,55 @@ +import { converse, framed, serve } from "./helpers.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Compression } from "../index.js"; +import type { Context } from "binden"; +import { join } from "node:path"; +import { test } from "node:test"; + +/** A file that `sendFile()` sends, which the compression compresses */ +const file = join(import.meta.dirname, "..", "package.json"); + +test("a sendFile() that fails once its compressed representation is chosen (its status has become informational) leaves no decision: the reply that follows gets one of its own (H29-3)", async () => { + let outcome: unknown = null; + let filtered = 0; + const served = await serve( + {}, + new Compression({ + threshold: 100, + // As the compressed representation of the file is chosen (once its + // headers are set), its status becomes informational: its send + // refuses it + filter: (context: Context): boolean => { + filtered += 1; + if (filtered === 1) { + context.response.statusCode = 103; + } + return true; + }, + }), + async ({ response }: Context): Promise => { + outcome = await response.sendFile(file).then( + () => null, + (error: unknown) => (error as Error).message, + ); + response.status(500).type("text/plain").end("err"); + }, + ); + try { + const responses = await converse( + served.url, + "GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n", + ); + deepStrictEqual(responses.length, 1); + const [response] = responses as [(typeof responses)[0]]; + ok(response.head.startsWith("http/1.1 500 "), response.head); + // Below the threshold: as it is + ok(!response.head.includes("\r\ncontent-encoding:"), response.head); + deepStrictEqual(framed(response).toString(), "err"); + } finally { + await served.close(); + } + deepStrictEqual( + outcome, + "Status code 103 is informational (use writeEarlyHints())", + ); +}); 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/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..d542516c --- /dev/null +++ b/packages/logger/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/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 00000000..986fcc01 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,123 @@ +# @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)), with default serializers that keep the input of the requests out of the logs. + +## Installation + +```bash +npm install @binden/logger +``` + +## Usage + +```typescript +import { Logger } from "@binden/logger"; + +const logger = new Logger({ level: "info" }); +logger.info("The server is listening", { port: 8080 }); +``` + +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. + +```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: one of `pino`, a custom one (`customLevels`) or `silent` (another one throws a `TypeError`) | +| `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): every other option set with it throws 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 | + +Options that are not an object (or `undefined`) throw a `TypeError` ("`options` is not an object: …"), and what reading them throws (e.g. a revoked proxy) is formatted, as by every entry point of binden. A level that is not a string (of the options, of `.level` and of `.child()`) is refused without being converted ("Unknown level: …"). Each option is read once, also the nested ones that `pino` reads several times or keeps: each level of `customLevels` (also of a function that carries them), the `hooks`, the options of `redact` and its paths, and the targets and steps of `transport` (also functions). `new Logger()` copies them as `pino` reads them, checks the copy and gives it to `pino`. For example, it copies the inherited `level` of a target only when the target has a pipeline, whose steps `pino` gives it. It reads `transport` in the order of `pino`: first whether it is a `SonicBoom` (by its prototype chain), then its `writable`, then its `_writableState` (also inherited, or not enumerable). By these `pino` refuses a stream given as `transport`, and nothing more of one that it refuses is read. Where the class of `SonicBoom` cannot be resolved from `pino` (e.g. in a bundle), the copy keeps the prototype of `transport`, by which `pino` tells one itself. Then it reads its `targets` (also inherited), by which `pino` refuses a `level` formatter with `targets`, and its own properties, as 2.0.3 did. A `transport` given with a `destination` that is a path is not read: `pino` refuses both first. A level of `customLevels` that is not a number and a path of `redact` that is not a string throw a `TypeError` ("`customLevels` has a level that is not a number: …", "`redact` has a path that is not a string: …"). Both are checked by their types before `pino` would convert them (it would write the records of a level given as an object with `valueOf()` with `"level": null`). + +`Logger.getLevel(name)` reads a level from another environment variable (a name that is not a string throws a `TypeError` without being converted): + +```typescript +import { Logger } from "@binden/logger"; + +const logger = new Logger({ level: Logger.getLevel("OTHER_ENV_NAME") }); +``` + +### Serializers + +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; +- `err`: the same as `error`, for the key under which `pino` logs an `Error` given as the fields of a record (e.g. `logger.error("Failed", error)`), which it would log whole otherwise (its message, stack and every property); +- `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), +}); +``` + +### Methods + +- `.fatal()`, `.error()`, `.warn()`, `.info()`, `.debug()`, `.trace()` - write a record with a message and optional fields + +```typescript +import log from "@binden/logger"; + +try { + JSON.parse("{"); +} catch (error) { + log.error("Something bad happened", { error }); +} +``` + +- `.child(bindings, { level })` - a logger that adds `bindings` to every record, with its own level if given (an unknown level throws a `TypeError`) + +```typescript +import log from "@binden/logger"; + +const child = log.child({ module: "cache" }, { level: "debug" }); +child.debug("Cache miss", { key: "user:1" }); +``` + +- `.level` - the level of the logger (it can be changed: an unknown level throws a `TypeError`), and `.isLevelEnabled(level)` - whether the records of a level are written (`false` for a level that is not a string) + +```typescript +import log from "@binden/logger"; + +if (log.isLevelEnabled("debug")) { + log.debug("An expensive record", { + details: JSON.stringify(process.memoryUsage()), + }); +} +log.level = "warn"; +``` + +- `.flush()` - write the buffered records (e.g. of an asynchronous destination) before the process exits + +```typescript +import log from "@binden/logger"; + +await log.flush(); +``` + +- `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 + +`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 new file mode 100644 index 00000000..c9c7f736 --- /dev/null +++ b/packages/logger/index.ts @@ -0,0 +1,909 @@ +import pino, { + type Bindings, + type DestinationStream, + type LevelWithSilent, + type Logger as PinoLogger, + type LoggerOptions as PinoLoggerOptions, +} from "pino"; +import { constants } from "node:os"; +import { createRequire } from "node:module"; + +/** The options of a `Logger` */ +export interface LoggerOptions extends PinoLoggerOptions { + /** 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; +} + +/** 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 { + /** 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; +} + +/** + * Formats the stack of an error that this package has just created, before + * it is thrown, as binden formats its own: V8 formats it once it is first + * read, and until then its frames hold their objects (e.g. the options of a + * logger, as the arguments of its constructor), which an error kept by the + * application would retain as long as it lives + */ +function formatStack(error: T): T { + try { + Reflect.get(error, "stack"); + } catch { + // A `prepareStackTrace` of the application that throws: the stack is + // left to it + } + return error; +} + +/** + * Runs the body of an entry point of this package (e.g. what reads or calls + * an argument): what it throws is formatted (see `formatStack()`) before the + * caller gets it, as binden formats it (e.g. the `TypeError` of V8 for a + * revoked proxy, or an error of `pino` for its options), and an error of the + * application's own (e.g. of a getter) is thrown as it is (formatted too) + */ +function formatting(run: () => T): T { + try { + return run(); + } catch (error: unknown) { + throw formatStack(error as Error); + } +} + +/** + * A value that an argument error of this package refuses, as its message + * shows it, without converting it (as binden describes it): a string, a + * number, a boolean, `undefined` or `null` as it is, a `bigint` with its `n`, + * and anything else by its type, so that neither a conversion throws nor code + * of the application runs (e.g. a `toString()`) + */ +function described(value: unknown): string { + switch (typeof value) { + case "string": + return value; + case "number": + case "boolean": + return `${value}`; + case "bigint": + return `${value}n`; + case "undefined": + return "undefined"; + case "symbol": + return "(a symbol)"; + case "function": + return "(a function)"; + case "object": + default: + return value === null ? "null" : "(an object)"; + } +} + +/** + * The options of an entry point (`name` in the message), checked before any + * of them is read (`undefined` is the default of its parameter): anything but + * an object is refused with a `TypeError` that describes it (see + * `described()`) + */ +function optionsOf(value: unknown, name: string): object { + if (typeof value !== "object" || value === null) { + throw formatStack( + new TypeError(`\`${name}\` is not an object: ${described(value)}`), + ); + } + return value; +} + +/** 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`, `err`, `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 same as `error`, for the key of `pino` (an `Error` given as `extra` + * is logged under it) + */ + err(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; +} + +/** + * 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( + options: { error_details?: boolean | undefined } = {}, +): ISerializers { + // Read once: what reading the options throws is formatted (see + // `formatting()`) + const { error_details = false } = formatting(() => { + const { error_details: given } = optionsOf(options, "options") as { + error_details?: unknown; + }; + return { error_details: given }; + }); + if (typeof error_details !== "boolean") { + throw formatStack(new TypeError("`error_details` is not a boolean")); + } + // The type of an error, a known `code` and its `status` (and, with + // `error_details`, its message, its stack and any `code`) + const error = (value: unknown): Record => { + const type = typeOf(value); + if (typeof value !== "object" && typeof value !== "function") { + return { type }; + } + const code = property(value, "code"); + const status = property(value, "status"); + const message = property(value, "message"); + const stack = property(value, "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 } : {}), + }; + }; + return { + error, + // `pino` logs an `Error` given as `extra` under `err`, which its own + // serializer would log whole (e.g. a `config` with credentials) + err: error, + // 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(); + +/** Whether a value is an object (not `null`), e.g. a nested option of `pino` */ +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Whether a value has properties that `pino` reads as those of an object: an + * object or a function (e.g. a `customLevels` that is a function carrying its + * levels) + */ +function hasProperties(value: unknown): value is Record { + return isObject(value) || typeof value === "function"; +} + +/** Sets a property that is not enumerable: `pino` reads it, but never spreads it */ +function hide(object: object, key: string, value: unknown): void { + Object.defineProperty(object, key, { + value, + enumerable: false, + writable: true, + configurable: true, + }); +} + +/** + * A copy of the own enumerable properties of an object (which `pino` spreads), + * with the properties under `keys` that it reads as they are (also inherited, + * or not enumerable) when they are not among them, each read once: those of + * `hidden` are not enumerable in the copy, since `pino` reads them but never + * spreads them + */ +function copyOf( + value: Record, + keys: readonly string[], + hidden: readonly string[] = [], +): Record { + const copy: Record = { ...value }; + for (const key of [...keys, ...hidden]) { + if (!Object.hasOwn(copy, key)) { + const read: unknown = Reflect.get(value, key); + if (typeof read === "undefined") { + // Nothing to copy + } else if (keys.includes(key)) { + copy[key] = read; + } else { + hide(copy, key, read); + } + } + } + return copy; +} + +/** + * A step of a pipeline of `transport`, copied as `pino` reads it: its own + * enumerable properties, which it spreads, and its `target` (also inherited), + * once. Its other inherited properties (e.g. a `level`) are ignored, as + * `pino` ignores them. A function is copied as an object (`pino` reads its + * properties as those of one). + */ +function copyStep(step: unknown): unknown { + return hasProperties(step) ? copyOf(step, ["target"]) : step; +} + +/** + * A target of `transport`, copied as `pino` reads it (see `copyStep()`), with + * its `pipeline` (also inherited), whose steps are copied too, and, for a + * target with a pipeline only, its `level` (also inherited), which `pino` + * gives to each of its steps: each once. Unless they are its own enumerable + * properties, `pino` never spreads them, nor does the copy. A function is + * copied as an object, as a step is. + */ +function copyTarget(target: unknown): unknown { + if (!hasProperties(target)) { + return target; + } + const copy = copyOf(target, ["target"], ["pipeline"]); + const { pipeline } = copy; + if (Array.isArray(pipeline)) { + copy.pipeline = [...(pipeline as unknown[])].map(copyStep); + } + if (Boolean(pipeline) && !Object.hasOwn(copy, "level")) { + hide(copy, "level", Reflect.get(target, "level")); + } + return copy; +} + +/** + * An object whose list under a key (the `targets` or the `pipeline` of + * `transport`), if it is an array, is a copy of its items (see `copyTarget()` + * and `copyStep()`) + */ +function withCopies( + object: Record, + key: string, + copy: (item: unknown) => unknown, +): Record { + const list = object[key]; + if (Array.isArray(list)) { + object[key] = [...(list as unknown[])].map(copy); + } + return object; +} + +/** + * The class of the streams of `pino` (`SonicBoom`, a function of CommonJS), by + * which it refuses one given as `transport` + */ +type SonicBoomClass = (...args: never) => unknown; + +// eslint-disable-next-line init-declarations -- resolved when it is first needed (see `sonicBoom()`) +let sonicBoomClass: SonicBoomClass | null | undefined; + +/** + * The `SonicBoom` class of `pino`, resolved from `pino` as `pino` resolves + * it (the same class), once, when a `transport` is first given, or `null` + * where it cannot be resolved (e.g. in a bundle): `pino` then tells one by the + * prototype that the copy of `transport` keeps (see `copyTransport()`) + */ +function sonicBoom(): SonicBoomClass | null { + if (typeof sonicBoomClass === "undefined") { + try { + sonicBoomClass = createRequire(import.meta.resolve("pino"))( + "sonic-boom", + ) as SonicBoomClass; + } catch { + sonicBoomClass = null; + } + } + return sonicBoomClass; +} + +/** + * `object`, with each of `values` that is not its own property yet as one, not + * enumerable (see `hide()`): `pino` reads them, but never spreads them + */ +function hiding( + object: Record, + values: ReadonlyMap, +): Record { + for (const [key, value] of values) { + if (!Object.hasOwn(object, key)) { + hide(object, key, value); + } + } + return object; +} + +/** + * `transport`, copied as `pino` reads it, in its order. First what it reads + * to refuse a stream, each only while it has not refused it: whether it is a + * `SonicBoom` (by its prototype chain), then its `writable`, then its + * `_writableState` (also inherited, or not enumerable). A refused one is + * copied as what `pino` refuses (a `SonicBoom`, or what it has read), and + * nothing else is read. Then its `targets` (also inherited, or not + * enumerable), which it reads to refuse a `level` formatter with them, and + * its own enumerable properties, which it spreads (with copies of its + * `targets` and its `pipeline`, see `copyTarget()` and `copyStep()`), reusing + * those read before: each is read once. The copy has what `pino` reads as it + * is but never spreads as its own properties, not enumerable, and no + * prototype, through which `pino` would read the transport again (but where + * the class of `SonicBoom` cannot be resolved, see `sonicBoom()`). A function + * is copied as an object. + */ +function copyTransport( + transport: Record, +): Record { + const SonicBoom = sonicBoom(); + if (SonicBoom !== null && transport instanceof SonicBoom) { + return Object.create(SonicBoom.prototype as object) as Record< + PropertyKey, + unknown + >; + } + // Without the class, the copy keeps the prototype, by which `pino` tells a + // `SonicBoom` itself + const prototype = + SonicBoom === null + ? (Object.getPrototypeOf(transport) as object | null) + : null; + const copy = Object.create(null) as Record; + const read = new Map(); + for (const key of ["writable", "_writableState"]) { + const value: unknown = Reflect.get(transport, key); + read.set(key, value); + // A truthy one: `pino` refuses a stream + const refused = Boolean(value); + if (refused) { + return hiding(copy, read); + } + } + read.set("targets", Reflect.get(transport, "targets")); + // Spread as `pino` spreads it: for each of its own keys, its descriptor, + // then the value of an enumerable one + for (const key of Reflect.ownKeys(transport)) { + if (Reflect.getOwnPropertyDescriptor(transport, key)?.enumerable === true) { + Object.defineProperty(copy, key, { + value: + typeof key === "string" && read.has(key) + ? read.get(key) + : Reflect.get(transport, key), + enumerable: true, + writable: true, + configurable: true, + }); + } + } + return Object.setPrototypeOf( + hiding( + withCopies(withCopies(copy, "targets", copyTarget), "pipeline", copyStep), + read, + ), + prototype, + ) as Record; +} + +// 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 { + 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; + } + }; + } + + /** + * A logger with the options of `pino` (and its safe serializers, merged by + * key with `serializers`), or one of an existing `pino` logger (`logger`, + * whose options apply: it cannot be combined with any other option). An + * invalid option (e.g. an unknown `level`, or a `logger` that is not a + * `pino` logger) throws a `TypeError`. + */ + public constructor(all: LoggerOptions = {}) { + // Read once, and checked: what reading them throws is formatted (see + // `formatting()`) + const { logger, destination, error_details, custom, options } = formatting( + () => Logger.#read(all), + ); + 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); + }; + // What `pino` throws for its options (e.g. a destination that it cannot + // read) is formatted (see `formatting()`) + this.#logger = Logger.#deferred ? null : formatting(this.#create); + } + + /** + * The options of a logger, each read once, and checked (see the + * constructor): anything but an object is refused with a `TypeError` + */ + static #read(all: unknown): { + readonly logger: PinoLogger | undefined; + readonly destination: DestinationStream | undefined; + readonly error_details: boolean; + readonly custom: NonNullable; + readonly options: PinoLoggerOptions; + } { + const given = optionsOf(all, "options") as LoggerOptions; + const { + logger, + destination, + error_details, + serializers: custom, + ...options + } = given; + if (typeof logger !== "undefined") { + // The other options, by their names in the order of the object + const named: Record = { + destination, + error_details, + serializers: custom, + ...options, + }; + Logger.#checkPino( + logger, + Object.keys(given).filter( + (name) => name !== "logger" && typeof named[name] !== "undefined", + ), + ); + } else if ( + typeof error_details !== "undefined" && + typeof error_details !== "boolean" + ) { + throw formatStack(new TypeError("`error_details` is not a boolean")); + } + // Read once (see `#copy()`): the checks and `pino` read the copy (no + // level is combined with `logger`) + const copied = Logger.#copy(options, destination); + Logger.#checkNested(copied); + if (typeof copied.level !== "undefined") { + const { customLevels = {}, useOnlyCustomLevels = false } = copied; + Logger.#checkLevel(copied.level, { + ...(useOnlyCustomLevels ? {} : pino.levels.values), + ...customLevels, + }); + } + return { + logger, + destination, + error_details: error_details ?? false, + custom: custom ?? {}, + options: copied, + }; + } + + /** + * The options of `pino`, with a copy of those that it reads several times + * (each level of `customLevels`, which the check of `level` reads too, the + * `hooks`, the paths of `redact`, and the targets and steps of + * `transport`), made as `pino` reads them: each is read once, so that a + * getter of the application runs once. A `transport` given with a + * `destination` that is a path is not read: `pino` refuses them before it + * reads anything of it + */ + static #copy( + options: PinoLoggerOptions, + destination: unknown, + ): PinoLoggerOptions { + const copied: Record = { ...options }; + const { customLevels, hooks, redact, transport } = copied; + if (hasProperties(customLevels)) { + // Also a function carrying its levels: `pino` reads their keys + copied.customLevels = { ...customLevels }; + } + if (hasProperties(hooks)) { + // Kept by `pino`, which reads `logMethod` (also inherited) at its + // creation and at every change of its level, and `streamWrite` at + // every record + copied.hooks = copyOf(hooks, ["logMethod", "streamWrite"]); + } + if (Array.isArray(redact)) { + copied.redact = [...(redact as unknown[])]; + } else if (isObject(redact)) { + // What `pino` reads of it (also inherited), as it reads them + const { paths, censor, remove } = redact; + copied.redact = { + paths: Array.isArray(paths) ? [...(paths as unknown[])] : paths, + censor, + remove, + }; + } + if (hasProperties(transport) && typeof destination !== "string") { + copied.transport = copyTransport(transport); + } + return copied; + } + + /** + * Throws a `TypeError` for a nested option that `pino` would convert: a + * level of `customLevels` that is not a number, and a path of `redact` that + * is not a string, checked by their types (never converted) + */ + static #checkNested({ customLevels, redact }: PinoLoggerOptions): void { + const levels: unknown = customLevels ?? {}; + for (const level of Object.values(levels as object) as unknown[]) { + if (typeof level !== "number") { + throw formatStack( + new TypeError( + `\`customLevels\` has a level that is not a number: ${described(level)}`, + ), + ); + } + } + const paths: unknown = Array.isArray(redact) + ? redact + : isObject(redact) + ? redact.paths + : null; + for (const path of Array.isArray(paths) ? (paths as unknown[]) : []) { + if (typeof path !== "string") { + throw formatStack( + new TypeError( + `\`redact\` has a path that is not a string: ${described(path)}`, + ), + ); + } + } + } + + /** A logger that writes with an existing `pino` logger (its options apply) */ + public static fromPino(logger: PinoLogger): Logger { + return new Logger({ logger }); + } + + /** The level of the logger */ + public get level(): LevelWithSilent { + return this.#pino().level as LevelWithSilent; + } + + /** Sets the level of the logger (an unknown one throws a `TypeError`) */ + public set level(level: LevelWithSilent) { + formatting(() => { + const logger = this.#pino(); + Logger.#checkLevel(level, logger.levels.values); + logger.level = level; + }); + } + + /** + * Whether the records of a level are written: never those of a level that + * is not a string (it is not converted) + */ + public isLevelEnabled(level: string): boolean { + const given: unknown = level; + return typeof given === "string" && this.#pino().isLevelEnabled(given); + } + + /** 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: an unknown one throws a `TypeError`) + */ + public child(bindings: Bindings = {}, options: IChildOptions = {}): Logger { + // What reading the arguments throws, also in `pino`, is formatted (see + // `formatting()`) + return formatting(() => { + const { level } = options; + const logger = this.#pino(); + if (typeof level !== "undefined") { + Logger.#checkLevel(level, logger.levels.values); + } + return Logger.fromPino( + typeof level === "undefined" + ? logger.child(bindings) + : logger.child(bindings, { level }), + ); + }); + } + + /** Logs a message (with extra fields) at the `fatal` level */ + public fatal(message: string, extra: Record = {}): void { + // What `pino` throws as it reads `extra` (e.g. a revoked proxy) is + // formatted (see `formatStack()`): inline, a hot path + try { + this.#pino().fatal(extra, message); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** Logs a message (with extra fields) at the `error` level */ + public error(message: string, extra: Record = {}): void { + // What `pino` throws as it reads `extra` (e.g. a revoked proxy) is + // formatted (see `formatStack()`): inline, a hot path + try { + this.#pino().error(extra, message); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** Logs a message (with extra fields) at the `warn` level */ + public warn(message: string, extra: Record = {}): void { + // What `pino` throws as it reads `extra` (e.g. a revoked proxy) is + // formatted (see `formatStack()`): inline, a hot path + try { + this.#pino().warn(extra, message); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** Logs a message (with extra fields) at the `info` level */ + public info(message: string, extra: Record = {}): void { + // What `pino` throws as it reads `extra` (e.g. a revoked proxy) is + // formatted (see `formatStack()`): inline, a hot path + try { + this.#pino().info(extra, message); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** Logs a message (with extra fields) at the `debug` level */ + public debug(message: string, extra: Record = {}): void { + // What `pino` throws as it reads `extra` (e.g. a revoked proxy) is + // formatted (see `formatStack()`): inline, a hot path + try { + this.#pino().debug(extra, message); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** Logs a message (with extra fields) at the `trace` level */ + public trace(message: string, extra: Record = {}): void { + // What `pino` throws as it reads `extra` (e.g. a revoked proxy) is + // formatted (see `formatStack()`): inline, a hot path + try { + this.#pino().trace(extra, message); + } catch (error: unknown) { + throw formatStack(error as Error); + } + } + + /** 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?: string): LevelWithSilent { + // A string before it is read, which would convert it (the default + // computed inside the formatting, see `formatting()`) + const given: unknown = + typeof env_name === "undefined" + ? formatting(() => this.ENV_VARIABLE_NAME) + : env_name; + if (typeof given !== "string") { + throw formatStack( + new TypeError(`\`env_name\` is not a string: ${described(given)}`), + ); + } + const { + env: { [given]: 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"; + } + } + + /** Throws a `TypeError` unless a level is `silent` or one of the known ones */ + static #checkLevel(level: unknown, known: Record): void { + if ( + typeof level !== "string" || + (level !== "silent" && !Object.hasOwn(known, level)) + ) { + // Described without converting it (see `described()`) + throw formatStack(new TypeError(`Unknown level: ${described(level)}`)); + } + } + + /** + * Throws a `TypeError` unless `logger` is a `pino` logger (with its methods) + * that no other option is combined with: they would not apply to it + */ + static #checkPino(logger: unknown, others: readonly string[]): void { + const methods = [ + "child", + "flush", + "isLevelEnabled", + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + ]; + if ( + typeof logger !== "object" || + logger === null || + // Read as they are: what a getter or a proxy throws is thrown + !methods.every((name) => typeof Reflect.get(logger, name) === "function") + ) { + throw formatStack(new TypeError("`logger` is not a `pino` logger")); + } + const [other] = others; + if (typeof other !== "undefined") { + throw formatStack( + new TypeError(`\`logger\` cannot be combined with \`${other}\``), + ); + } + } + + /** The `pino` logger, created on first use for the default logger */ + #pino(): PinoLogger { + this.#logger ??= this.#create(); + return this.#logger; + } +} + +/** The default logger: its level is `BINDEN_LOG_LEVEL` (`silent` without it) */ +export default createDefault(); diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 00000000..269548f1 --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,59 @@ +{ + "name": "@binden/logger", + "version": "0.0.0-development", + "description": "A simple logger (based on pino)", + "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" + }, + "sideEffects": false, + "files": [ + "dist", + "!dist/test", + "index.ts" + ], + "engines": { + "node": ">=24.14.1" + }, + "scripts": { + "build": "tsc", + "prebuild": "node ../../scripts/clean.mjs", + "prepack": "npm run build", + "test": "node ../../scripts/test.mjs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/logger" + }, + "keywords": [ + "binden", + "logger", + "logging", + "pino", + "json", + "structured-logging" + ], + "author": "Sergey Bakulin ", + "license": "MIT", + "bugs": { + "url": "https://github.com/binden-js/binden/issues" + }, + "homepage": "https://github.com/binden-js/binden/tree/main/packages/logger#readme", + "dependencies": { + "pino": "^10.4.0" + }, + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } +} diff --git a/packages/logger/test/arguments.test.ts b/packages/logger/test/arguments.test.ts new file mode 100644 index 00000000..00c83474 --- /dev/null +++ b/packages/logger/test/arguments.test.ts @@ -0,0 +1,248 @@ +import { Logger, type LoggerOptions, createSerializers } from "../index.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import pino from "pino"; + +/** An error of the application's own (e.g. of a getter, or of a trap) */ +class OwnError extends Error {} + +/** What a call has thrown, and whether it was formatted then */ +interface IOutcome { + readonly thrown: unknown; + readonly formatted: boolean; +} + +/** + * Runs a call while `Error.prepareStackTrace` records each error whose stack + * V8 formats: what it throws, and whether that was formatted before the + * caller got it (`null` when it does not throw) + */ +function outcome(run: () => unknown): IOutcome | null { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + run(); + return null; + } catch (thrown: unknown) { + return { + thrown, + formatted: typeof thrown === "object" && formatted.has(thrown as object), + }; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A revoked proxy */ +function revoked(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** A proxy whose every trap throws `error` */ +function throwing(error: Error): object { + const trap = (): never => { + throw error; + }; + return new Proxy( + {}, + { get: trap, getOwnPropertyDescriptor: trap, has: trap, ownKeys: trap }, + ); +} + +/** A destination that keeps nothing */ +const sink = { + write: (): void => { + // Nothing + }, +}; + +describe("The entry points of the logger format what they throw (round 27)", () => { + it("refuses options that are not an object, and formats what reading them throws", () => { + for (const [run, message] of [ + [ + (): unknown => new Logger(null as never), + "`options` is not an object: null", + ], + [(): unknown => new Logger(5 as never), "`options` is not an object: 5"], + [ + (): unknown => createSerializers("a" as never), + "`options` is not an object: a", + ], + ] as const) { + const failed = outcome(run); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + [message, true], + ); + } + for (const run of [ + (): unknown => new Logger(revoked()), + (): unknown => createSerializers(revoked()), + (): unknown => new Logger({ destination: revoked() as never }), + ]) { + const failed = outcome(run); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + } + for (const run of [ + (own: Error): unknown => new Logger(throwing(own)), + (own: Error): unknown => createSerializers(throwing(own)), + // Read as it is: what its trap throws is not a refusal + (own: Error): unknown => Logger.fromPino(throwing(own) as never), + (own: Error): unknown => + new Logger({ destination: sink }).child(throwing(own)), + (own: Error): unknown => + new Logger({ destination: sink }).child({}, throwing(own)), + ]) { + const own = new OwnError("own"); + deepStrictEqual( + outcome(() => run(own)), + { thrown: own, formatted: true }, + ); + } + }); + + it("names the first option combined with `logger`, in the order of the options", () => { + const failed = outcome( + () => + new Logger({ + level: "info", + logger: pino({ level: "info" }, sink), + destination: sink, + }), + ); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + failed.thrown.message, + "`logger` cannot be combined with `level`", + ); + }); + + it("refuses a level, or the name of a variable, that is not a string without converting it", () => { + let conversions = 0; + const counting = { + toString(): string { + conversions += 1; + return "info"; + }, + }; + const logger = new Logger({ destination: sink, level: "info" }); + for (const run of [ + (): unknown => + new Logger({ destination: sink, level: counting as never }), + (): unknown => { + logger.level = counting as never; + return null; + }, + (): unknown => logger.child({}, { level: counting as never }), + ]) { + const failed = outcome(run); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + ["Unknown level: (an object)", true], + ); + } + for (const [level, message] of [ + [1n, "Unknown level: 1n"], + [({} as { value?: undefined }).value, "Unknown level: undefined"], + [Symbol("s"), "Unknown level: (a symbol)"], + [(): null => null, "Unknown level: (a function)"], + [true, "Unknown level: true"], + ] as const) { + const refused = outcome(() => { + logger.level = level as never; + }); + ok(refused?.thrown instanceof TypeError); + deepStrictEqual(refused.thrown.message, message); + } + deepStrictEqual(logger.isLevelEnabled(counting as never), false); + deepStrictEqual(logger.isLevelEnabled("info"), true); + const failed = outcome(() => Logger.getLevel(counting as never)); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + ["`env_name` is not a string: (an object)", true], + ); + deepStrictEqual(conversions, 0); + // Without its class as the receiver, the default variable cannot be read + const getLevel = Reflect.get(Logger, "getLevel") as () => unknown; + const detached = outcome(() => Reflect.apply(getLevel, null, [])); + ok(detached?.thrown instanceof TypeError); + ok(detached.formatted); + }); + + it("refuses a level of `customLevels` that is not a number, and a path of `redact` that is not a string, without converting them", () => { + let conversions = 0; + const counting = { + toString(): string { + conversions += 1; + return "30"; + }, + }; + for (const [options, message] of [ + [ + { customLevels: { custom: counting } }, + "`customLevels` has a level that is not a number: (an object)", + ], + [ + { customLevels: { custom: "35" } }, + "`customLevels` has a level that is not a number: 35", + ], + [ + { redact: [counting] }, + "`redact` has a path that is not a string: (an object)", + ], + [ + { redact: { paths: ["a", 1] } }, + "`redact` has a path that is not a string: 1", + ], + ] as const) { + const failed = outcome( + () => new Logger({ destination: sink, ...(options as LoggerOptions) }), + ); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + [message, true], + ); + } + deepStrictEqual(conversions, 0); + // Valid ones (`null` is none, as for `pino`) + for (const options of [ + { customLevels: { custom: 35 } }, + { redact: { paths: ["a"] } }, + { customLevels: null as never }, + ]) { + deepStrictEqual( + outcome(() => new Logger({ destination: sink, ...options })), + null, + ); + } + }); + + it("formats what `pino` throws as it reads the fields of a record", () => { + const logger = new Logger({ destination: sink, level: "trace" }); + for (const method of [ + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + ] as const) { + const failed = outcome(() => { + logger[method]("message", revoked() as never); + }); + ok(failed?.thrown instanceof TypeError, method); + ok(failed.formatted, method); + } + }); +}); diff --git a/packages/logger/test/fixtures/channel.mjs b/packages/logger/test/fixtures/channel.mjs new file mode 100644 index 00000000..59bc36b1 --- /dev/null +++ b/packages/logger/test/fixtures/channel.mjs @@ -0,0 +1,21 @@ +// A `pino` transport (loaded by its worker) that posts the message of every +// record it gets on the `BroadcastChannel` named by its `channel` option: a +// target of the tests of `transport`, which tells them what reaches it +import { Writable } from "node:stream"; + +export default function channel({ channel: name }) { + const out = new BroadcastChannel(name); + let rest = ""; + return new Writable({ + write(chunk, _encoding, callback) { + const lines = `${rest}${chunk}`.split("\n"); + rest = lines.pop(); + for (const line of lines) { + if (line !== "") { + out.postMessage(JSON.parse(line).msg); + } + } + callback(); + }, + }); +} diff --git a/packages/logger/test/fixtures/discard.mjs b/packages/logger/test/fixtures/discard.mjs new file mode 100644 index 00000000..99ebeb8c --- /dev/null +++ b/packages/logger/test/fixtures/discard.mjs @@ -0,0 +1,11 @@ +// A `pino` transport (loaded by its worker) that discards the records: a +// target of the tests of `transport`, which writes nowhere +import { Writable } from "node:stream"; + +export default function discard() { + return new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); +} 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/fixtures/unresolved.ts b/packages/logger/test/fixtures/unresolved.ts new file mode 100644 index 00000000..802327f3 --- /dev/null +++ b/packages/logger/test/fixtures/unresolved.ts @@ -0,0 +1,40 @@ +// Run by `logger.test.ts`: where the `SonicBoom` class of `pino` cannot be +// resolved (e.g. in a bundle), the copy of `transport` keeps its prototype, by +// which `pino` refuses a `SonicBoom` itself +import { registerHooks } from "node:module"; + +registerHooks({ + resolve: (specifier, context, next) => { + // Only from `pino` itself: what `pino` loads resolves as before + if ( + specifier === "sonic-boom" && + context.parentURL?.endsWith("/pino/pino.js") === true + ) { + process.stdout.write("unresolved\n"); + throw new Error(`Cannot find module '${specifier}'`); + } + return next(specifier, context); + }, +}); + +const { Logger } = await import("../../index.js"); +const { default: pino } = await import("pino"); + +const sonic = Object.getPrototypeOf( + pino.destination({ dest: 1, sync: true }), +) as object; +const target = new URL("discard.mjs", import.meta.url).href; +for (const transport of [ + Object.create(sonic) as object, + Object.assign(Object.create(sonic) as object, { target }), + { writable: true, target }, + Object.create({ _writableState: {} }) as object, + { target }, +]) { + try { + new Logger({ transport: transport as never }).info("discarded"); + process.stdout.write("accepted\n"); + } catch (error: unknown) { + process.stdout.write(`${(error as Error).message}\n`); + } +} diff --git a/packages/logger/test/formatted-errors.test.ts b/packages/logger/test/formatted-errors.test.ts new file mode 100644 index 00000000..00eb9084 --- /dev/null +++ b/packages/logger/test/formatted-errors.test.ts @@ -0,0 +1,54 @@ +import { Logger, createSerializers } from "../index.js"; +import { describe, it } from "node:test"; +import { ok, strictEqual } from "node:assert/strict"; + +/** + * Runs `run`, which throws, while `Error.prepareStackTrace` is `prepare`: + * what it throws + */ +function thrown(run: () => unknown, prepare: unknown): unknown { + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", prepare); + try { + run(); + } catch (error: unknown) { + return error; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } + return null; +} + +describe("An error that the logger throws", () => { + for (const [what, run] of [ + [ + "an invalid `error_details` of the serializers", + (): unknown => createSerializers({ error_details: 1 as never }), + ], + [ + "an unknown level", + (): unknown => new Logger({ level: "verbose" as never }), + ], + ] as const) { + it(`is formatted when it is created: ${what}`, () => { + const formatted = new WeakSet(); + const error = thrown(run, (failure: Error): string => { + formatted.add(failure); + return `${failure.name}: ${failure.message}`; + }); + ok(error instanceof TypeError); + ok(formatted.has(error)); + }); + } + + it("is thrown as it is when a `prepareStackTrace` of the application throws: its stack is left to it", () => { + const error = thrown( + () => createSerializers({ error_details: 1 as never }), + (): never => { + throw new Error("prepareStackTrace"); + }, + ); + ok(error instanceof TypeError); + strictEqual(error.message, "`error_details` is not a boolean"); + }); +}); diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts new file mode 100644 index 00000000..397fbe5c --- /dev/null +++ b/packages/logger/test/logger.test.ts @@ -0,0 +1,1147 @@ +import { deepEqual, equal, ok, rejects, throws } from "node:assert/strict"; +import { describe, it } from "node:test"; +import log, { + Logger, + type LoggerOptions, + createSerializers, + serializers, +} from "../index.js"; +import pino, { type LevelWithSilent, type Logger as PinoLogger } from "pino"; +import { Writable } from "node:stream"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +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, +}; + +/** An object (or an array) whose values are getters that count their reads */ +function countedIn( + reads: Map, + name: string, + values: T, +): T { + return Object.defineProperties( + Array.isArray(values) ? [] : {}, + Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + key, + { + enumerable: true, + get: (): unknown => { + const at = `${name}.${key}`; + reads.set(at, (reads.get(at) ?? 0) + 1); + return value as unknown; + }, + }, + ]), + ), + ) as T; +} + +/** + * The messages of the records that reach the targets of the tests that post + * them on a channel (see `fixtures/channel.mjs`), once `last` is among them: + * a failure after 10 seconds without it + */ +function received(name: string, last: string): Promise { + const channel = new BroadcastChannel(name); + const messages: string[] = []; + const { promise, resolve, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + channel.close(); + reject(new Error(`No "${last}" on ${name}, but: ${messages.join(", ")}`)); + }, 10_000); + channel.onmessage = ({ data }: MessageEvent): void => { + messages.push(data); + if (data === last) { + clearTimeout(timer); + channel.close(); + resolve(messages); + } + }; + return promise; +} + +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 an error by default without its details", () => { + const sink = new Sink(); + const logger = new Logger({ destination: sink, level: "error" }); + + logger.error("failed", { + error: Object.assign(new RangeError("secret"), { code: "ECONNRESET" }), + }); + + deepEqual(sink.lines[0].error, { type: "RangeError", code: "ECONNRESET" }); + }); + + it("serializes an error under `err` (the key of `pino`) as under `error`", () => { + const sink = new Sink(); + const logger = new Logger({ destination: sink, level: "error" }); + const secret = Object.assign(new Error("password=hunter2"), { + code: "E_SECRET", + config: { headers: { authorization: "Bearer SECRET" } }, + }); + + logger.error("err", { err: secret }); + // An `Error` as `extra` is logged under `err` by `pino` + logger.error("extra", secret as unknown as Record); + + deepEqual( + sink.lines.map(({ msg, err }) => [msg, err]), + [ + ["err", { type: "Error" }], + ["extra", { type: "Error" }], + ], + ); + deepEqual(serializers.err(secret), serializers.error(secret)); + }); + + 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({ error_details: 1 as unknown as boolean }), + new TypeError("`error_details` is not a boolean"), + ); + throws( + () => createSerializers({ error_details: "no" as unknown as boolean }), + new TypeError("`error_details` is not a boolean"), + ); + for (const level of ["verbose", "INFO", "", 30]) { + throws( + () => new Logger({ destination: new Sink(), level: level as string }), + new TypeError(`Unknown level: ${level}`), + ); + } + // The custom levels, and `silent` + const customLevels = { audit: 35 }; + equal(new Logger({ level: "audit", customLevels }).level, "audit"); + equal(new Logger({ level: "silent" }).level, "silent"); + throws( + () => + new Logger({ level: "info", customLevels, useOnlyCustomLevels: true }), + new TypeError("Unknown level: info"), + ); + }); + + it("refuses the options combined with `logger`, and a `logger` that is not a `pino` logger", () => { + const logger = pino({ level: "warn" }, new Sink()); + const combined: [LoggerOptions, string][] = [ + [{ destination: new Sink() }, "destination"], + [{ level: "debug" }, "level"], + [{ error_details: true }, "error_details"], + [{ error_details: false }, "error_details"], + [{ serializers: {} }, "serializers"], + ]; + for (const [options, name] of combined) { + throws( + () => new Logger({ ...options, logger }), + new TypeError(`\`logger\` cannot be combined with \`${name}\``), + ); + } + // Options that are not set + const unset: unknown = Reflect.get({}, "unset"); + equal(new Logger({ logger, level: unset } as LoggerOptions).level, "warn"); + for (const invalid of [null, {}, "pino", { ...logger, flush: null }]) { + throws( + () => new Logger({ logger: invalid as unknown as PinoLogger }), + new TypeError("`logger` is not a `pino` logger"), + ); + } + }); + + it("reads once each nested option that `pino` reads several times: the levels of `customLevels`, the paths of `redact` and the targets of `transport`", () => { + const reads = new Map(); + const counted = (name: string, values: T): T => + countedIn(reads, name, values); + const sink = new Sink(); + const logger = new Logger({ + destination: sink, + level: "custom", + customLevels: counted("customLevels", { custom: 35 }), + redact: counted("redact", { + paths: counted("paths", ["secret"]), + censor: "[hidden]", + }), + }); + logger.warn("warn", { secret: "s" }); + logger.info("info", { secret: "s" }); + // Without a level (`silent`: nothing is written), and the paths as a list + new Logger({ + destination: sink, + customLevels: counted("defaults", { other: 15 }), + redact: counted("list", ["secret"]), + }).error("error", { secret: "s" }); + // A URL: also on Windows, where `pino` prefixes a path with `file://` + const target = new URL("fixtures/discard.mjs", import.meta.url).href; + new Logger({ + transport: counted("transport", { + targets: counted("targets", [counted("target", { target })]), + }), + }).info("discarded"); + new Logger({ + transport: { + pipeline: counted("pipeline", [counted("step", { target })]), + }, + }).info("discarded"); + // What `pino` reads as it is (also inherited): a target, the pipeline of + // a target and its level (see below), and the options of `redact` + new Logger({ + transport: { + targets: [ + Object.create( + counted("inherited", { target, level: "info" }), + ) as never, + counted("piped", { + level: "warn", + pipeline: counted("steps", [counted("next", { target })]), + }), + ], + }, + }).info("discarded"); + new Logger({ + destination: sink, + level: "info", + redact: Object.create(counted("options", { paths: ["secret"] })) as never, + }).info("inherited", { secret: "s" }); + deepEqual( + sink.lines.map(({ level, msg, secret }) => [level, msg, secret]), + [ + [40, "warn", "[hidden]"], + [30, "inherited", "[Redacted]"], + ], + ); + // What `pino` refuses itself: a target that is not an object, and the + // options of `redact` without a list of paths + throws(() => new Logger({ transport: { targets: [null as never] } }), { + name: "TypeError", + }); + throws(() => new Logger({ transport: { pipeline: [null as never] } }), { + name: "TypeError", + }); + throws(() => new Logger({ destination: sink, redact: {} as never }), { + message: "pino – redact must contain an array of strings", + }); + deepEqual(Object.fromEntries(reads), { + "customLevels.custom": 1, + "redact.paths": 1, + "redact.censor": 1, + "paths.0": 1, + "defaults.other": 1, + "list.0": 1, + "transport.targets": 1, + "targets.0": 1, + "target.target": 1, + "pipeline.0": 1, + "step.target": 1, + "inherited.target": 1, + "piped.level": 1, + "piped.pipeline": 1, + "steps.0": 1, + "next.target": 1, + "options.paths": 1, + }); + }); + + it("copies the targets and the steps of `transport` as `pino` reads them: an inherited `level` only for a target with a pipeline", async () => { + const reads = new Map(); + const counted = (name: string, values: T): T => + countedIn(reads, name, values); + const target = new URL("fixtures/channel.mjs", import.meta.url).href; + const named = (key: string): string => + `binden-logger-${process.pid}-${key}`; + const to = (key: string): Record => ({ + target, + options: { channel: named(key) }, + }); + const inheriting = ( + key: string, + inherited: Record, + own: Record, + ): never => + Object.assign(Object.create(counted(key, inherited)), own) as never; + const transports = [ + // The inherited `level` of a target, and of a step, is ignored + { targets: [inheriting("target", { level: "error" }, to("target"))] }, + { pipeline: [inheriting("step", { level: "error" }, to("step"))] }, + // Not the one of a target with a pipeline (also inherited): its steps + // get it + { + targets: [ + inheriting("piped", { level: "error" }, { pipeline: [to("piped")] }), + ], + }, + { + targets: [ + inheriting( + "inherited", + { + level: "error", + pipeline: [to("inherited")], + }, + {}, + ), + ], + }, + // Nor is it spread into the target itself + { + targets: [ + inheriting( + "both", + { level: "error" }, + { + ...to("both"), + pipeline: [to("both-steps")], + }, + ), + ], + }, + ]; + const expected = { + target: ["info", "error"], + step: ["info", "error"], + piped: ["error"], + inherited: ["error"], + both: ["info", "error"], + "both-steps": ["error"], + }; + const records = Promise.all( + Object.keys(expected).map((key) => received(named(key), "error")), + ); + for (const transport of transports) { + const logger = new Logger({ level: "info", transport }); + logger.info("info"); + logger.error("error"); + } + deepEqual(await records, Object.values(expected)); + deepEqual(Object.fromEntries(reads), { + "piped.level": 1, + "inherited.level": 1, + "inherited.pipeline": 1, + "both.level": 1, + }); + }); + + it("copies a target or a step of `transport` that is a function as an object (and a `transport` that is one): its `target` read once", async () => { + const reads = new Map(); + const target = new URL("fixtures/channel.mjs", import.meta.url).href; + const named = (key: string): string => + `binden-logger-${process.pid}-callable-${key}`; + const callable = (key: string): (() => null) => + Object.defineProperties(() => null, { + target: { + enumerable: true, + get: (): string => { + reads.set(key, (reads.get(key) ?? 0) + 1); + return target; + }, + }, + options: { enumerable: true, value: { channel: named(key) } }, + }); + const records = Promise.all( + ["target", "step", "nested"].map((key) => received(named(key), "info")), + ); + for (const transport of [ + { targets: [callable("target")] }, + { pipeline: [callable("step")] }, + Object.assign(() => null, { targets: [callable("nested")] }), + ]) { + new Logger({ level: "info", transport: transport as never }).info("info"); + } + deepEqual(await records, [["info"], ["info"], ["info"]]); + deepEqual(Object.fromEntries(reads), { target: 1, step: 1, nested: 1 }); + }); + + it("refuses a stream given as `transport` as `pino` does, by what it reads to tell one (also inherited, or not enumerable): each read once, also its own ones, which `pino` reads again as it spreads them", () => { + const target = new URL("fixtures/discard.mjs", import.meta.url).href; + const stream = + "option.transport do not allow stream, please pass to option directly. e.g. pino(transport)"; + // E.g. a `SonicBoom` that does not write (`stdout`, synchronously) + const sonic = Object.getPrototypeOf( + pino.destination({ dest: 1, sync: true }), + ) as object; + for (const transport of [ + new Writable(), + Object.create(new Writable()) as object, + Object.assign(Object.create({ writable: true }) as object, { target }), + Object.defineProperty({ target }, "writable", { value: true }), + Object.assign(Object.create({ _writableState: {} }) as object, { + target, + }), + Object.assign(Object.create(sonic) as object, { target }), + Object.setPrototypeOf( + Object.assign(() => null, { target }), + { + writable: true, + }, + ) as object, + ]) { + throws(() => new Logger({ transport: transport as never }), { + message: stream, + }); + } + // Not a stream (a `writable` that is not true), and its own properties + new Logger({ + transport: Object.assign(Object.create({ writable: 0 }) as object, { + target, + }) as never, + }).info("discarded"); + // The `targets` that `pino` reads to refuse a `level` formatter (it + // spreads only its own) + for (const transport of [ + Object.assign(Object.create({ targets: [{ target }] }) as object, { + target, + }), + Object.defineProperty({ target }, "targets", { value: [{ target }] }), + ]) { + throws( + () => + new Logger({ + transport: transport as never, + formatters: { level: (label: string): object => ({ label }) }, + }), + { + message: + "option.transport.targets do not allow custom level formatters", + }, + ); + } + const reads = new Map(); + const prototype = countedIn(reads, "prototype", { + writable: false, + _writableState: null, + targets: null, + }); + new Logger({ + transport: Object.setPrototypeOf({ target }, prototype) as never, + }).info("discarded"); + // Its own (which `pino` reads again as it spreads them): reused + new Logger({ + transport: countedIn(reads, "own", { + writable: false, + _writableState: null, + targets: 0, + target, + }), + }).info("discarded"); + deepEqual(Object.fromEntries(reads), { + "prototype.writable": 1, + "prototype._writableState": 1, + "prototype.targets": 1, + "own.writable": 1, + "own._writableState": 1, + "own.targets": 1, + "own.target": 1, + }); + }); + + it("reads `transport` in the order of `pino`, as `pino` itself does: whether it is a `SonicBoom`, its `writable`, its `_writableState`, its `targets`, then its own properties, nothing after what refuses it or throws", () => { + const target = new URL("fixtures/discard.mjs", import.meta.url).href; + const sonic = Object.getPrototypeOf( + pino.destination({ dest: 1, sync: true }), + ) as object; + // A proxy that logs what is read of it: its prototype, its keys, the + // descriptors and the values of its properties + const traced = (trace: string[], name: string, object: object): object => + new Proxy(object, { + getPrototypeOf: (from): object | null => { + trace.push(`${name}: prototype`); + return Reflect.getPrototypeOf(from); + }, + ownKeys: (from): (string | symbol)[] => { + trace.push(`${name}: keys`); + return Reflect.ownKeys(from); + }, + getOwnPropertyDescriptor: ( + from, + key, + ): PropertyDescriptor | undefined => { + trace.push(`${name}: descriptor of ${String(key)}`); + return Reflect.getOwnPropertyDescriptor(from, key); + }, + get: (from, key, receiver): unknown => { + trace.push(`${name}: ${String(key)}`); + return Reflect.get(from, key, receiver) as unknown; + }, + }); + const throwing = (message: string): PropertyDescriptor => ({ + get: (): never => { + throw new Error(message); + }, + }); + // Never read: `pino` has refused the transport (or thrown) before + const unread = { + writable: throwing("writable is read"), + _writableState: throwing("_writableState is read"), + targets: throwing("targets is read"), + target: { ...throwing("target is read"), enumerable: true }, + }; + const falsy = { + writable: { value: false }, + _writableState: { value: null }, + targets: { value: 0 }, + }; + // A transport whose properties are inherited (from a prototype that + // derives from `base`), or its own, not enumerable (the spread ones are + // its own, enumerable) + const transport = + ( + properties: PropertyDescriptorMap, + { + base = Object.prototype, + inherited = false, + }: { base?: object; inherited?: boolean } = {}, + ) => + (trace: string[]): object => { + const { target: spread, ...read } = { + ...unread, + ...properties, + }; + const parent = traced( + trace, + "prototype", + Object.create(base, inherited ? read : {}) as object, + ); + return traced( + trace, + "transport", + Object.create(parent, { + ...(inherited ? {} : read), + target: spread, + }) as object, + ); + }; + const stream = + "Error: option.transport do not allow stream, please pass to option directly. e.g. pino(transport)"; + // A prototype that cannot be read + const { proxy: revoked, revoke } = Proxy.revocable({}, {}); + revoke(); + const cases: [string, (trace: string[]) => object, string][] = []; + for (const inherited of [true, false]) { + const at = inherited ? "inherited" : "own"; + cases.push( + [ + `${at}: a SonicBoom`, + transport({}, { base: sonic, inherited }), + stream, + ], + [ + `${at}: a prototype that throws`, + transport( + {}, + { + base: revoked, + inherited, + }, + ), + "", + ], + [ + `${at}: a writable`, + transport({ writable: { value: 1 } }, { inherited }), + stream, + ], + [ + `${at}: a writable that throws`, + transport( + { writable: throwing("writable has failed") }, + { inherited }, + ), + "Error: writable has failed", + ], + [ + `${at}: a _writableState`, + transport( + { writable: falsy.writable, _writableState: { value: {} } }, + { inherited }, + ), + stream, + ], + [ + `${at}: a _writableState that throws`, + transport( + { + writable: falsy.writable, + _writableState: throwing("_writableState has failed"), + }, + { inherited }, + ), + "Error: _writableState has failed", + ], + [ + `${at}: targets that throw`, + transport( + { ...falsy, targets: throwing("targets has failed") }, + { inherited }, + ), + "Error: targets has failed", + ], + [ + `${at}: an own property that throws`, + transport( + { + ...falsy, + target: { ...throwing("target has failed"), enumerable: true }, + }, + { inherited }, + ), + "Error: target has failed", + ], + [ + `${at}: accepted`, + transport( + { ...falsy, target: { value: target, enumerable: true } }, + { inherited }, + ), + "accepted", + ], + ); + } + // What is read, in order, and the outcome + const outcome = ( + create: (transport: object) => unknown, + make: (trace: string[]) => object, + ): [string[], string] => { + const trace: string[] = []; + const given = make(trace); + try { + create(given); + return [trace, "accepted"]; + } catch (error: unknown) { + return [trace, `${(error as Error).name}: ${(error as Error).message}`]; + } + }; + for (const [name, make, expected] of cases) { + const native = outcome( + (given) => pino({ transport: given as never }), + make, + ); + const copied = outcome( + (given) => new Logger({ transport: given as never }), + make, + ); + deepEqual(copied, native, name); + // The revoked prototype: the `TypeError` of V8, whose message varies + ok( + expected === "" + ? native[1].startsWith("TypeError: ") + : native[1] === expected, + `${name}: ${native[1]}`, + ); + } + }); + + it("does not read a `transport` given with a `destination` that is a path: `pino` refuses both before it reads anything of it", () => { + const traps: string[] = []; + // Every trap is logged + const handler = new Proxy( + {}, + { + get: + (_handler, trap: keyof typeof Reflect) => + (...args: never[]): unknown => { + traps.push(trap); + return (Reflect[trap] as (...all: never[]) => unknown)(...args); + }, + }, + ); + // Typed as a stream: `pino` takes a path too + const destination = join( + import.meta.dirname, + "missing", + "never.log", + ) as never; + const refusal = { + message: "only one of option.transport or stream can be specified", + }; + throws( + () => pino({ transport: new Proxy({}, handler) as never }, destination), + refusal, + ); + throws( + () => + new Logger({ + transport: new Proxy({}, handler) as never, + destination, + }), + refusal, + ); + deepEqual(traps, []); + }); + + it("refuses a `SonicBoom` given as `transport` where its class cannot be resolved (e.g. in a bundle): by the prototype that the copy keeps, as `pino` does", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(import.meta.dirname, "fixtures", "unresolved.ts"), + ], + { encoding: "utf8", timeout: 30_000 }, + ); + const stream = + "option.transport do not allow stream, please pass to option directly. e.g. pino(transport)"; + deepEqual( + { status, stdout, stderr }, + { + status: 0, + stdout: `${["unresolved", stream, stream, stream, stream, "accepted"].join("\n")}\n`, + stderr: "", + }, + ); + }); + + it("reads a `customLevels` that is a function as an object: each of its levels once", () => { + let reads = 0; + const customLevels = Object.defineProperty(() => null, "custom", { + enumerable: true, + get: (): number => { + reads += 1; + return reads === 1 ? 35 : 5; + }, + }); + const sink = new Sink(); + const logger = new Logger({ + destination: sink, + level: "custom" as LevelWithSilent, + customLevels, + }); + logger.info("below"); + logger.warn("above"); + equal(reads, 1); + deepEqual( + sink.lines.map(({ level, msg }) => [level, msg]), + [[40, "above"]], + ); + }); + + it("copies `hooks` once: its `logMethod` and `streamWrite` (also inherited), read once each", () => { + const reads = new Map(); + const hooks = Object.defineProperties( + Object.create( + countedIn(reads, "inherited", { + logMethod( + this: PinoLogger, + args: unknown[], + method: (...given: unknown[]) => void, + ): void { + method.apply(this, [args[0], `hooked ${String(args[1])}`]); + }, + }), + ) as object, + Object.getOwnPropertyDescriptors( + countedIn(reads, "own", { + streamWrite: (line: string): string => + line.replace("hooked", "written"), + }), + ), + ); + const sink = new Sink(); + const logger = new Logger({ + destination: sink, + level: "info", + hooks, + }); + logger.info("one"); + // `pino` reads `logMethod` at every change of its level + logger.level = "debug"; + logger.level = "info"; + logger.child({ id: 1 }).info("two"); + logger.warn("three"); + deepEqual( + sink.lines.map(({ msg }) => msg), + ["written one", "written two", "written three"], + ); + deepEqual(Object.fromEntries(reads), { + "inherited.logMethod": 1, + "own.streamWrite": 1, + }); + }); + + 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")); + throws(() => { + logger.level = "nope" as LevelWithSilent; + }, new TypeError("Unknown level: nope")); + equal(logger.level, "debug"); + const custom = new Logger({ customLevels: { audit: 35 } }); + custom.level = "audit" as LevelWithSilent; + equal(custom.level, "audit"); + }); + + 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]], + ); + throws( + () => logger.child({}, { level: "verbose" as LevelWithSilent }), + new TypeError("Unknown level: verbose"), + ); + const custom = new Logger({ customLevels: { audit: 35 } }); + const audit = "audit" as LevelWithSilent; + equal(custom.child({}, { level: audit }).level, audit); + }); + + 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" }); + }); +}); 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/CHANGELOG.md b/packages/multipart/CHANGELOG.md new file mode 100644 index 00000000..ed9b2c2b --- /dev/null +++ b/packages/multipart/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## [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) +- 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..d542516c --- /dev/null +++ b/packages/multipart/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/packages/multipart/README.md b/packages/multipart/README.md new file mode 100644 index 00000000..c5c01465 --- /dev/null +++ b/packages/multipart/README.md @@ -0,0 +1,232 @@ +# @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)). + +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. + +The parser does not decode content codings: a `multipart/form-data` body with a `Content-Encoding` other than `identity` (e.g. `gzip`) gets `415 Unsupported Media Type`, before it is read. + +## Installation + +```bash +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. Its constructor (`new MultipartFile(bits, name, options)`) takes the arguments of `File`, and `truncated`, a boolean (`false` by default: anything else throws a `TypeError`, "`truncated` is not a boolean: …", before the content is read): the constructor of `File` of Node.js converts the name, `type` and `lastModified` as it does for a plain `File` (e.g. a name that is not a string, by `String()`), no more often, and what it throws is formatted (see `formatStack()` of binden). + +```typescript +import { type Context, Router } from "binden"; +import multipart, { MultipartFile } from "@binden/multipart"; + +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 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("/upload", new Router().post(formData, upload)); +``` + +`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. + +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"; + +app.use(new BodyParser(), new Multipart({ storage: {} })); +``` + +### 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: + +| 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, multipartInfo } from "@binden/multipart"; + +const formData = new Multipart({ + throw_limits: false, + limit: 1024 * 1024, + config: { limits: { fieldSize: 16 * 1024, fields: 4, files: 4, parts: 8 } }, +}); + +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("/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 where there cannot be one: another file system, or one without hard links), which must not exist: an existing file or symbolic link is an `EEXIST` error, and is never replaced. A copy writes into a new file that it creates itself (`O_EXCL`): a copy that fails removes that file, and only while `target` is still that file (the same device and inode, without following a symbolic link). A copy that cannot even read the identity of the file that it has created (its `fstat()` fails) closes it and leaves it, empty: without that identity, nothing tells it from a file that has replaced it. The directory of `target` must be trusted: nothing can make that check and the removal atomic against another process that renames files in it. 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`). + +The files are stored until the cleanup of the request, which starts once the response is closed. A body received before it is stored as usual (and its files removed by the cleanup, unless persisted), but a parse that goes on after it (e.g. one that has not been awaited before the reply) refuses the files that come later, as no cleanup would remove them: `run()` rejects with a `500` (`A file cannot be stored once the cleanup of the request has started`). + +```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 content coding other than `identity` (`Content-Encoding`) | `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` | +| A parse that starts once the response has finished | `500 Internal Server Error` | +| Another media type than `multipart/form-data` | Skipped: the body is not read | +| The client goes away | `run()` rejects with a `400` of code `aborted` | +| The deadline passes, or the server shuts down | `run()` rejects with the reason of `context.signal` (a `BindenAbortError`) | +| A file once the cleanup of the request has started (disk) | `run()` rejects with a `500` | +| A use of the context that throws once the parse has started (its request, response or signal, or the setter of `request.body`; the calls of its logger never fail, and what getting it throws drops the records of the parse only) | `run()` rejects with that error, formatted (the first one) | +| A `file_filter` or `on_file` that throws a value whose check throws (e.g. a revoked proxy) | `run()` rejects with the error of that check, formatted | + +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 whose headers have been sent destroys the request (its reply cannot tell the error): a send whose headers are not sent yet (e.g. of a stream that has not given its first chunk) is left to the framework, whose reply closes the connection. + +Each property of the context is read once, as the parse starts (`onCleanup` when the disk storage stores its first file). What reading `log` throws drops the records of the parse only. A later failure refuses the parse as any refusal does: listeners removed, request unpiped and paused, busboy and the file streams destroyed. What reading `onCleanup` throws is the rejection, and what calling it throws is the `500` above, with that error as its cause. + +After the deadline, the framework replies `503 Service Unavailable` and closes the connection (a send that has not sent its headers is cancelled first). When the server shuts down and its `timeout` passes, the connection is closed without a reply. + +A parse must start before the response has finished: Node.js then reads and discards what has not been read of the body. A parse that starts later (e.g. after a middleware that has replied without `done`) refuses the body with a `500` (logged at `error`), never with an empty form. + +A client that goes away is seen also when the parser is still reading once the response has finished (e.g. it was not awaited): `context.signal` is no longer aborted then, but the connection closes before the body has been received whole, and `run()` rejects with a `400` of code `aborted`. The streams of the files in progress are destroyed (a body received whole is still parsed, and with the disk storage its files are stored until the cleanup of the request). + +The middleware logs with `context.log`, as `{ middleware: "Multipart" }`. diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts new file mode 100644 index 00000000..911718ed --- /dev/null +++ b/packages/multipart/index.ts @@ -0,0 +1,25 @@ +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 { IStoredFile, IStoredForm } from "./src/disk.js"; +export { + type IMultipartInfo, + type ILimitName, + multipartInfo, +} from "./src/info.js"; +export type { + IDiskStorage, + IFileFilter, + IFileInfo, + IMultipartConfig, + IMultipartOptions, + IStorage, + IStreamFile, + IStreamStorage, +} from "./src/options.js"; + +/** A `Multipart` of the options (see `IMultipartOptions`) */ +export default (options?: IMultipartOptions): Multipart => + new Multipart(options); diff --git a/packages/multipart/package.json b/packages/multipart/package.json new file mode 100644 index 00000000..335c5daa --- /dev/null +++ b/packages/multipart/package.json @@ -0,0 +1,70 @@ +{ + "name": "@binden/multipart", + "version": "0.0.0-development", + "description": "Binden middleware for parsing incoming FormData", + "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" + }, + "sideEffects": false, + "files": [ + "dist", + "!dist/test", + "index.ts", + "src" + ], + "engines": { + "node": ">=24.14.1" + }, + "scripts": { + "build": "tsc", + "prebuild": "node ../../scripts/clean.mjs", + "prepack": "npm run build", + "test": "node ../../scripts/test.mjs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/multipart" + }, + "keywords": [ + "multipart", + "formdata", + "form-data", + "upload", + "file-upload", + "middleware", + "HTTP", + "binden", + "busboy" + ], + "author": "Sergey Bakulin ", + "license": "MIT", + "bugs": { + "url": "https://github.com/binden-js/binden/issues" + }, + "homepage": "https://github.com/binden-js/binden/tree/main/packages/multipart#readme", + "dependencies": { + "@types/busboy": "^1.5.5", + "busboy": "^1.6.0" + }, + "peerDependencies": { + "binden": "^0.0.0-development" + }, + "devDependencies": { + "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/multipart/src/disk.ts b/packages/multipart/src/disk.ts new file mode 100644 index 00000000..9af8922b --- /dev/null +++ b/packages/multipart/src/disk.ts @@ -0,0 +1,432 @@ +import { type BigIntStats, type WriteStream, constants } from "node:fs"; +import { BindenError, formatStack } from "binden"; +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 into a new file that the copy + * creates (`O_EXCL`) on another file system (or one without hard links). + * A failed copy removes only the file that it has created, and only while + * it is still there: the directory of `target` must be trusted (nothing can + * make that check and the removal atomic). A copy that cannot read the + * identity of the file that it has created leaves it, empty. 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 { + /** The values of the fields, by name */ + readonly fields: Map; + /** The stored files, by the name of their field */ + readonly files: Map; +} + +/** 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; + /** The status of a path, without following a symbolic link (see `fs.lstat()`) */ + lstat(path: string, options: { bigint: true }): Promise; + /** Removes a file (see `fs.unlink()`) */ + unlink(path: string): Promise; + /** Removes an empty directory (see `fs.rmdir()`) */ + 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 }; + +/** The identity of a file: its device and its inode */ +interface IIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + +/** + * The errors of a hard link after which the file is copied: another file + * system (`EXDEV`), one without hard links (`EPERM`, `ENOTSUP`), or a file + * with too many links (`EMLINK`) + */ +const unlinkable: ReadonlySet = new Set([ + "EXDEV", + "EPERM", + "ENOTSUP", + "EMLINK", +]); + +/** + * The refusal of a file once the cleanup of the request has started (the + * response is closed): the cleanup would not remove it + */ +export function cleanedUp(cause?: unknown): BindenError { + const message = + "A file cannot be stored once the cleanup of the request has started"; + return new BindenError(500, { message, cause }); +} + +/** 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 { + /** 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; + 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(); + + /** 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, + 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); + } + + /** 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 formatStack(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 formatStack( + new Error("The file has already been persisted elsewhere"), + ); + } + return; + } + const temp = this.#temp; + if (temp === null) { + throw formatStack(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), as the cleanup has started */ + public stop(): void { + this.#stopped = true; + this.#output?.destroy(cleanedUp()); + } + + /** 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 cleanedUp(); + } + 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, which never replaces one, where there cannot be a hard link + */ + async #move(source: string, target: string): Promise { + try { + await this.#system.link(source, target); + return; + } catch (error: unknown) { + if (!unlinkable.has(codeOf(error))) { + throw error; + } + } + await this.#copy(source, target); + } + + /** + * Copies the file into a new file that the copy creates (`O_EXCL`: never + * one that exists), through its own descriptor. A copy that fails removes + * that file, unless `target` is no longer it (its device and inode, without + * following a symbolic link): when that identity cannot be read, the file + * is closed and left (empty), never removed. + */ + async #copy(source: string, target: string): Promise { + const { O_CREAT, O_EXCL, O_WRONLY } = constants; + const output = await this.#system.open( + target, + O_CREAT | O_EXCL | O_WRONLY, + 0o600, + ); + let created: IIdentity | null = null; + try { + const { dev, ino } = await output.stat({ bigint: true }); + created = { dev, ino }; + await this.#fill(source, output); + } catch (error: unknown) { + // Unless its stream has closed it (closing it again does nothing) + await output.close().catch(ignore); + if (created !== null) { + await this.#removeCreated(target, created).catch(ignore); + } + throw error; + } + } + + /** + * Writes the content of the file `source` through the descriptor of + * `output`: the streams close both descriptors once they are done (a + * failure to close `output`, e.g. to write what is left, fails the copy) + */ + async #fill(source: string, output: FileHandle): Promise { + const input = await this.#system.open(source, constants.O_RDONLY, 0); + try { + await pipeline(input.createReadStream(), output.createWriteStream()); + } finally { + // Unless its stream has closed it + await input.close(); + } + } + + /** Removes `target` if it is still the file that a copy has created */ + async #removeCreated(target: string, created: IIdentity): Promise { + const { dev, ino } = await this.#system.lstat(target, { bigint: true }); + if (dev === created.dev && ino === created.ino) { + await this.#system.unlink(target); + } + } + + /** 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; + } +} + +/** Appends a value to the list of its name (in place: linear in the count of the values) */ +function append(lists: Map, name: string, value: T): void { + const list = lists.get(name); + if (list) { + list.push(value); + } else { + lists.set(name, [value]); + } +} + +/** + * The body of the disk storage: the fields and the stored files of the + * parts, by name, in their order (the other parts, e.g. skipped files, are + * left out) + */ +export function storedForm( + parts: Iterable<{ readonly name: string; readonly value: unknown }>, +): IStoredForm { + const fields = new Map(); + const files = new Map(); + for (const { name, value } of parts) { + if (typeof value === "string") { + append(fields, name, value); + } else if (value instanceof StoredFile) { + append(files, name, value); + } + } + return { fields, files }; +} + +/** + * 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 (a `500`), which would leave it behind + */ + public add(info: IFileInfo, stream: IFileStream): StoredFile { + if (this.#cleaned) { + throw cleanedUp(); + } + 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 formatStack( + new AggregateError(errors, "The files have not all been removed"), + ); + } + } +} diff --git a/packages/multipart/src/file.ts b/packages/multipart/src/file.ts new file mode 100644 index 00000000..b6f0839f --- /dev/null +++ b/packages/multipart/src/file.ts @@ -0,0 +1,59 @@ +import { described, formatStack } from "binden"; + +/** 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; +} + +/** + * Whether the content of each `MultipartFile` has been truncated: a class + * with private fields cannot call the constructor of `File` inside a `try` + * (see `MultipartFile`) + */ +const truncations = new WeakMap(); + +/** + * 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 { + /** + * A file of the content, the name and the options of a `File`, and + * `truncated`, a boolean (anything else throws a `TypeError` that describes + * it, before the constructor of `File` runs): what reading them throws, and + * the errors of the constructor of `File` (e.g. for content that is not a + * sequence), are formatted (see `formatStack()`) + */ + public constructor( + bits: ConstructorParameters[0], + name: string, + init: IMultipartFileOptions = {}, + ) { + // Read before the constructor of `File`, which may throw + const read = { truncated: false }; + try { + const { truncated = false, ...options } = init; + if (typeof truncated !== "boolean") { + // Never converted + throw formatStack( + new TypeError( + `\`truncated\` is not a boolean: ${described(truncated)}`, + ), + ); + } + read.truncated = truncated; + super(bits, name, options); + } catch (error: unknown) { + throw formatStack(error as Error); + } + truncations.set(this, read.truncated); + } + + /** Whether the content has been truncated to `fileSize` */ + public get truncated(): boolean { + return truncations.get(this) === true; + } +} 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/info.ts b/packages/multipart/src/info.ts new file mode 100644 index 00000000..e770fdcd --- /dev/null +++ b/packages/multipart/src/info.ts @@ -0,0 +1,53 @@ +import { type Context, formatting } 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 { + /** 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[]; +} + +/** 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 { + // What reading the context throws is formatted (see `formatting()`) + const info = formatting(() => 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 new file mode 100644 index 00000000..ffeaebad --- /dev/null +++ b/packages/multipart/src/multipart.ts @@ -0,0 +1,740 @@ +import { + BindenAbortError, + BindenError, + type Context, + Middleware, + childLogger, + formatStack, + formatting, + record, + serializers, +} from "binden"; +import { + DiskStore, + type IFileStream, + type IStoredFile, + cleanedUp, + storedForm, +} from "./disk.js"; +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"; +import busboy, { + type Busboy, + type BusboyConfig, + type FieldInfo, + type FileInfo, +} from "busboy"; +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"]; + +/** What a parse uses of its context, each read once (see `Multipart#start()`) */ +interface IExchange { + /** Given to the callbacks of the options, and asked for the cleanup (disk) */ + readonly context: Context; + readonly request: Context["request"]; + readonly response: Context["response"]; + readonly signal: AbortSignal; + readonly headers: Context["request"]["headers"]; + /** A child of `context.log` */ + readonly log: ILog; +} + +/** + * The first failure of a late call of the context, formatted: the rejection + * of the parse (what is thrown may be anything, an error or not) + */ +interface IFailure { + readonly error: Error; +} + +/** A part, in the order of the request (a file once it has been read) */ +interface ISlot { + readonly name: string; + value: IStoredFile | MultipartFile | 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; + +/** 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(); +} + +/** + * Whether a `Content-Encoding` header has no content coding but `identity`: + * the parser does not decode a body + */ +function identity(header: string | undefined): boolean { + return (header ?? "").split(",").every((element) => { + const coding = element.trim().toLowerCase(); + return coding === "" || coding === "identity"; + }); +} + +/** 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 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` (as for `@binden/body`) + */ +function abortError(reason: unknown): BindenAbortError | BindenError { + if (reason instanceof BindenAbortError && reason.reason !== "client_closed") { + return reason; + } + const message = "The request has been aborted"; + return new BindenError(400, { message, code: "aborted", cause: reason }); +} + +/** 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[] = []; + 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 #request: Context["request"]; + readonly #response: Context["response"]; + readonly #signal: AbortSignal; + readonly #headers: Context["request"]["headers"]; + readonly #log: ILog; + readonly #info: IParseInfo; + readonly #config: IMultipartConfig; + readonly #throw_limits: boolean; + 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(); + readonly #slots: ISlot[] = []; + readonly #tasks: Promise[] = []; + readonly #streams = new Set(); + readonly #detach: (() => void)[] = []; + #busboy: Busboy | null = null; + #store: DiskStore | null = null; + #over = false; + #failure: IFailure | null = null; + + public constructor( + { context, request, response, signal, headers, log }: IExchange, + info: IParseInfo, + { + config, + throw_limits, + limit, + file_filter, + filtered, + mode, + }: IParserOptions, + ) { + this.#context = context; + this.#request = request; + this.#response = response; + this.#signal = signal; + this.#headers = headers; + this.#log = log; + this.#info = info; + this.#config = config; + this.#throw_limits = throw_limits; + this.#limit = limit; + this.#file_filter = file_filter; + this.#filtered = filtered; + this.#mode = mode; + } + + /** + * Settles once the body is parsed (`request.body`), or refused: what the + * context throws (as the parse starts, or later) refuses it with that + * error, formatted (see `#failed()`) + */ + public run(): Promise { + try { + this.#begin(); + } catch (error: unknown) { + this.#failed(error); + } + return this.#deferred.promise; + } + + /** Starts to read the body, unless it can be refused at once */ + #begin(): void { + const request = this.#request; + const signal = this.#signal; + const headers = this.#headers; + if (signal.aborted) { + this.#abort(); + return; + } + const onAbort = (): void => { + this.#late(() => { + this.#abort(); + }); + }; + signal.addEventListener("abort", onAbort, { once: true }); + this.#detach.push(() => { + signal.removeEventListener("abort", onAbort); + }); + + if (!identity(headers["content-encoding"])) { + // Busboy would parse the coded bytes + const message = "The body has a content coding, which is not decoded"; + this.#refuse(new BindenError(415, { message })); + return; + } + + 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; + } + + const length = Number(headers["content-length"] ?? 0); + if (length > this.#limit) { + this.#tooLarge(); + return; + } + + const parser = create({ ...this.#config, headers }); + if (parser instanceof Error) { + this.#malformed(parser); + return; + } + 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(parser, name, stream, info); + }) + .once("fieldsLimit", () => { + this.#countReached("fields"); + }) + .once("filesLimit", () => { + this.#countReached("files"); + }) + .once("partsLimit", () => { + this.#countReached("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); + this.#watch(); + } + + /** + * Watches the connection: once the response has finished, `context.signal` + * is no longer aborted when the client goes away, and Node.js no longer + * tells the request, but the connection closes (before, the signal is + * aborted first). A body that has been received whole is still read. + */ + #watch(): void { + const request = this.#request; + const { socket } = request; + const closed = (): void => { + this.#late(() => { + if (!request.complete) { + this.#refuse(abortError(null)); + } + }); + }; + socket.once("close", closed); + this.#detach.push(() => { + socket.off("close", closed); + }); + } + + #onField( + name: string | undefined, + 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") { + // 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.#info.truncated_fields.push(field); + if (this.#throw_limits) { + this.#limitReached("fieldSize"); + return; + } + } + this.#slots.push({ name: field, value }); + } + + #onFile( + parser: Busboy, + name: string | undefined, + stream: IFileStream, + { filename, encoding, 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); + 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.#late(() => { + this.#refuse(failure(stream.errored, message)); + }); + } + }); + stream.once("limit", () => { + this.#limitReached("fileSize"); + }); + 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( + 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), also with a value whose check + // throws (e.g. a revoked proxy) + this.#late(() => { + 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; + } 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, { + type: info.mime_type, + truncated: stream.truncated === true, + }); + } + + /** + * 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(); + } + } + + /** + * Stores a file on the disk, in the private directory of the request + * (created with its first file, and removed with its cleanup): a `500` + * once the cleanup of the request has started (see `cleanedUp()`) + */ + async #disk( + dir: string | null, + info: IFileInfo, + stream: IFileStream, + ): Promise { + if (this.#store === null) { + const context = this.#context; + let onCleanup: unknown = null; + try { + onCleanup = Reflect.get(context, "onCleanup"); + } catch (error: unknown) { + // A read of the context, which refuses the parse with what it throws + this.#failed(error); + throw error; + } + try { + this.#store = new DiskStore(dir ?? tmpdir(), (cleanup) => { + Reflect.apply(onCleanup as Context["onCleanup"], context, [cleanup]); + }); + } catch (error: unknown) { + // `onCleanup()` throws once the cleanup has started + throw cleanedUp(formatStack(error as Error)); + } + } + 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; + if (filter === null) { + return true; + } + const accepted: unknown = await race( + () => filter({ ...info }, this.#context), + this.#stop.signal, + ); + if (typeof accepted !== "boolean") { + throw formatStack( + 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); + this.#limitReached(limit); + } + + /** 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`): the client has gone away + * (`400`), or the deadline has passed or the server is shutting down (the + * reason of the signal) + */ + #abort(): void { + this.#refuse(abortError(this.#signal.reason)); + } + + /** Refuses the request with an error of the parser (see `#halt()`) */ + #refuse(error: BindenAbortError | BindenError): void { + this.#halt( + error, + error instanceof BindenError + ? { status: error.status } + : { reason: error.reason }, + ); + } + + /** + * A call of the context (of its request, its response, its signal or its + * logger), or a check of what it gives, has thrown: the parse is refused + * with what it has thrown (the first failure, see `#record()`) + */ + #failed(error: unknown): void { + const failed = this.#record(error).error; + this.#halt(failed, { failure: serializers.error(failed) }); + } + + /** Keeps the first failure of the context, formatted: the rejection */ + #record(error: unknown): IFailure { + this.#failure ??= { error: formatStack(error as Error) }; + return this.#failure; + } + + /** A call of the context while the parse stops (or ends): its failure is kept */ + #guard(call: () => void): void { + try { + call(); + } catch (error: unknown) { + this.#record(error); + } + } + + /** A callback (e.g. of the signal or of the request): its failure refuses the parse */ + #late(call: () => void): void { + try { + call(); + } catch (error: unknown) { + this.#failed(error); + } + } + + /** + * Refuses the request: the body is not read further, nor parsed. The + * connection is kept until the reply, which closes it, unless the headers + * of the response have already been sent. A send whose headers are not + * sent yet (e.g. of a stream that has not given its first chunk) is left + * to the framework: its reply (or the `503` that cancels it at the + * deadline) closes the connection. What the context throws meanwhile does + * not stop the cleanup: the first failure is the rejection. + */ + #halt(error: Error, logged: Record): void { + if (this.#over) { + return; + } + this.#end(); + this.#stop.abort(error); + const request = this.#request; + const response = this.#response; + const reader = this.#busboy; + if (reader) { + this.#guard(() => { + request.unpipe(reader); + }); + } + this.#guard(() => { + if (response.headersSent) { + // The reply cannot tell the error + request.destroy(); + } else { + request.pause(); + 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.#guard(() => { + record(this.#log, "debug", "The multipart body has been refused", logged); + }); + this.#deferred.reject(this.#failure === null ? error : this.#failure.error); + } + + /** The body has been parsed: every file has been read */ + #complete(): void { + if (this.#over) { + return; + } + this.#end(); + const request = this.#request; + const body = + this.#mode.kind === "disk" ? storedForm(this.#slots) : this.#form(); + this.#guard(() => { + request.body = body; + }); + const kept = this.#failure; + if (kept === null) { + this.#deferred.resolve(null); + } else { + this.#deferred.reject(kept.error); + } + } + + /** The fields and the files in memory (skipped ones excluded) */ + #form(): FormData { + const form = new FormData(); + for (const { name, value } of this.#slots) { + if (typeof value === "string" || value instanceof MultipartFile) { + form.append(name, value); + } + } + return form; + } + + #end(): void { + this.#over = true; + for (const detach of this.#detach) { + this.#guard(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 #options: IParserOptions; + + /** + * Checks the options: a `TypeError` or a `RangeError` for an invalid one. + * What reading them throws (e.g. a revoked proxy, or a getter that throws) + * is formatted (see `formatting()`). + */ + public constructor(options: IMultipartOptions = {}) { + const { middleware, parser } = formatting(() => validateOptions(options)); + super(middleware); + this.#options = parser; + } + + /** + * Parses the body of a `multipart/form-data` request: a limit gives a + * `413`, a malformed body (or boundary) a `400`, a client that goes away a + * `400` (`aborted`), a deadline or a shutdown the reason of the signal + */ + public async run(context: Context): Promise { + // What reading the context throws (e.g. of a value that is not a + // context) is formatted (see `formatting()`): it is read before anything + // is awaited + const parsing = formatting(() => this.#start(context)); + if (parsing !== null) { + await parsing; + } + } + + /** + * Starts the parse of the body of the request of a context (see `run()`): + * `null` when it is not parsed + */ + #start(context: Context): Promise | null { + // Each property of the context is read once + const { request } = context; + // What getting it throws (e.g. a `log` getter of a subclass of `Context`) + // drops the records of the parse, never the parse, as binden drops its + // own (a logger of binden over nothing drops them) + let parent: unknown = null; + try { + parent = context.log; + } catch { + // Dropped + } + // Made by the private path of binden: never a `child()` (nor a method) + // that the application has replaced + const log = childLogger(parent as Context["log"], { + middleware: Multipart.name, + }); + const { method = "GET", headers } = request; + const type = mediaType(headers["content-type"]); + + if (methods.has(method)) { + record(log, "debug", "The method has no body to parse", { method }); + return null; + } else if (type !== "multipart/form-data") { + record(log, "debug", "The body is not `multipart/form-data`", { type }); + return null; + } + const info = startParse(request); + if (info === null) { + record(log, "debug", "The body has already been parsed"); + return null; + } + const { response, signal } = context; + if (response.writableFinished) { + // Node.js reads and discards what has not been read once the response + // has finished: what is left of the body would be missing + const message = "The body has been discarded once the response finished"; + record(log, "error", message); + throw new BindenError(500, { message }); + } + const exchange = { context, request, response, signal, headers, log }; + return new Parser(exchange, info, this.#options).run(); + } +} diff --git a/packages/multipart/src/options.ts b/packages/multipart/src/options.ts new file mode 100644 index 00000000..7c1e32e4 --- /dev/null +++ b/packages/multipart/src/options.ts @@ -0,0 +1,363 @@ +import type { BusboyConfig, Limits } from "busboy"; +import { type Context, type IMiddlewareParams, formatStack } 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 + * the sizes of its buffers (its `headers` are those of the request: a + * `headers` key is ignored) + */ +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, 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) */ + 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; + +/** 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; +} + +/** + * 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"] }; + +/** 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; + /** + * 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; + /** + * Where the files go: in memory, as a `MultipartFile` of the `FormData` + * (`"memory"`), on the disk (`{ dir }`), or to a handler (`{ on_file }`) + */ + storage?: IStorage | undefined; +} + +/** 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; +} + +/** The options of `Multipart` that its parent class takes */ +type IMiddlewareOptions = Pick; + +const optionNames: ReadonlySet = new Set([ + "disabled", + "ignore_errors", + "config", + "throw_limits", + "limit", + "file_filter", + "filtered", + "storage", +]); + +const storageNames: ReadonlySet = new Set(["dir", "on_file"]); + +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 formatStack(new TypeError(`\`${name}\` is not an object`)); + } + for (const key of Object.keys(value)) { + if (!keys.has(key)) { + throw formatStack(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 formatStack(new TypeError(`\`${name}\` is not a number`)); + } else if ( + value !== Number.POSITIVE_INFINITY && + !(Number.isSafeInteger(value) && value >= 0) + ) { + throw formatStack( + 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 formatStack(new TypeError(`\`${name}\` is not a number`)); + } else if (!(Number.isSafeInteger(value) && value >= 0)) { + throw formatStack( + 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 formatStack(new TypeError(`\`${name}\` is not a string`)); + } else if (!charsets.has(value.toLowerCase())) { + throw formatStack( + 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 formatStack(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 storage of the files: its directory is resolved now */ +function checkStorage(value: unknown): IMode { + if (value === "memory") { + return { kind: "memory" }; + } + const storage = checkObject(value, "storage", storageNames); + const { dir, on_file } = storage; + if ("on_file" in storage) { + if ("dir" in storage) { + throw formatStack( + new TypeError("`storage.dir` and `storage.on_file` are exclusive"), + ); + } else if (typeof on_file !== "function") { + throw formatStack(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 formatStack(new TypeError("`storage.dir` is not a non-empty string")); + } + return { kind: "disk", dir: resolve(dir) }; +} + +/** + * 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, + file_filter = null, + filtered = "reject", + storage = "memory", + } = checkObject(input, "options", optionNames); + if (typeof throw_limits !== "boolean") { + throw formatStack(new TypeError("`throw_limits` is not a boolean")); + } else if (file_filter !== null && typeof file_filter !== "function") { + throw formatStack(new TypeError("`file_filter` is not a function")); + } else if (filtered !== "reject" && filtered !== "skip") { + throw formatStack(new TypeError('`filtered` is not "reject" or "skip"')); + } + return { + middleware: { + disabled: disabled as boolean | undefined, + ignore_errors: ignore_errors as boolean | undefined, + }, + parser: { + config: checkConfig(config), + throw_limits, + limit: checkSize(limit, "limit"), + file_filter: file_filter as IFileFilter | null, + filtered, + mode: checkStorage(storage), + }, + }; +} 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/aborted.test.ts b/packages/multipart/test/aborted.test.ts new file mode 100644 index 00000000..4e7d7287 --- /dev/null +++ b/packages/multipart/test/aborted.test.ts @@ -0,0 +1,438 @@ +import { Agent, type IncomingMessage, request as httpRequest } from "node:http"; +import { + Binden, + BindenAbortError, + BindenError, + type Context, + shutdown, +} from "binden"; +import { type IMultipartOptions, Multipart } from "../index.js"; +import { + RecordingLogger, + body, + listen, + part, + send, + serve, + sleep, + type, + upload, + within, +} from "./helpers.js"; +import { type TestContext, describe, it } from "node:test"; +import { deepEqual, equal, ok } from "node:assert/strict"; +import { Readable } from "node:stream"; +import { finished } from "node:stream/promises"; +import { once } from "node:events"; + +/** + * What `run()` has settled with: the reason of a `BindenAbortError`, or the + * status, code and cause of a `BindenError` + */ +function outcome(error: unknown): unknown[] { + if (error instanceof BindenAbortError) { + return ["BindenAbortError", error.reason]; + } + 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("rejects with the reason of the signal 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)), ["BindenAbortError", "deadline"]); + }); + + it("replies `503` at the deadline while a send waits for the first chunk of its stream", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: sent, resolve: finish } = Promise.withResolvers(); + const parser = new Multipart(); + const { url } = await serve( + t, + [ + async (context: Context): Promise => { + // Not awaited: the reply starts while the body is read + parser.run(context).then(settle, settle); + // A stream that gives nothing: the headers are never sent + const stream = new Readable({ + read(): void { + // Nothing + }, + }); + await context.send(stream).then(finish, (error: unknown) => { + finish(error); + throw error; + }); + }, + ], + { timeout: 100 }, + ); + + const reply = await upload( + url, + part('name="file"; filename="file.txt"', "Hello"), + ); + + // Not a closed connection: the deadline cancels the send, then replies + deepEqual(reply, [503, "close"]); + deepEqual(outcome(await within(settled)), ["BindenAbortError", "deadline"]); + deepEqual(outcome(await within(sent)), ["BindenAbortError", "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)), ["BindenAbortError", "deadline"]); + }); + + it("rejects with the reason of the signal when the server shuts down", async () => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: started, resolve: start } = Promise.withResolvers(); + const parser = new Multipart(); + const { server, url } = await listen( + new Binden().use(async (context: Context): Promise => { + start(null); + await parser.run(context).catch((error: unknown) => { + settle(error); + throw error; + }); + }), + ); + + const reply = upload(url, part('name="file"; filename="file.txt"', "1")); + await within(started); + const closed = shutdown(server, { timeout: 50 }); + + deepEqual(outcome(await within(settled)), ["BindenAbortError", "shutdown"]); + // Then every connection is closed + await within(reply.catch(() => null)); + await within(closed); + }); +}); + +/** Ignores an error (e.g. of a connection that the test closes on purpose) */ +function ignore(): void { + // On purpose +} + +/** + * The client of a parse that starts before the response, which finishes + * while the parse goes on: it sends the head of a body, reads the response, + * and goes away. Settles with what `run()` has settled with. + */ +async function afterResponse( + t: TestContext, + storage: IMultipartOptions["storage"], + reached: Promise, +): Promise { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const parser = new Multipart({ storage }); + const { url } = await serve(t, [ + async (context: Context): Promise => { + // Not awaited: the response finishes while the body is read + parser.run(context).then(settle, settle); + await context.send("early"); + }, + ]); + + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": 1 << 20 }, + }); + client.on("error", ignore); + client.write(part('name="file"; filename="file.txt"', "Hello")); + const [response] = (await within(once(client, "response"))) as [ + IncomingMessage, + ]; + response.resume(); + await within(once(response, "end")); + await within(reached); + client.destroy(); + return within(settled); +} + +describe("Multipart (a client that goes away once the response has finished)", () => { + it("rejects with a `400` (memory storage)", async (t) => { + const settled = await afterResponse(t, "memory", Promise.resolve()); + + deepEqual(outcome(settled), [400, "aborted", "object"]); + }); + + it("rejects with a `400`, and destroys the stream of the file (stream storage)", async (t) => { + const { promise: reached, resolve: reach } = Promise.withResolvers(); + const { promise: streamed, resolve: stream } = + Promise.withResolvers(); + const settled = await afterResponse( + t, + { + on_file: async ({ stream: content }): Promise => { + reach(null); + content.resume(); + await finished(content).then( + () => { + stream("ended"); + }, + () => { + stream(content.destroyed ? "destroyed" : "failed"); + }, + ); + }, + }, + reached, + ); + + deepEqual(outcome(settled), [400, "aborted", "object"]); + equal(await within(streamed), "destroyed"); + }); + + it("parses a body received whole, also when its connection then closes", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: closed, resolve: close } = + Promise.withResolvers(); + const parser = new Multipart(); + const { url } = await serve(t, [ + async (context: Context): Promise => { + const { request } = context; + parser.run(context).then(settle, settle); + // As if the rest of the body had been received, and were still on + // its way to the parser when the connection closes + request.complete = true; + request.socket.once("close", () => { + close(request); + }); + await context.send("early"); + }, + ]); + const form = body(part('name="a"', "1")); + + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": form.length }, + }); + client.on("error", ignore); + client.write(form.slice(0, 10)); + const [response] = (await within(once(client, "response"))) as [ + IncomingMessage, + ]; + response.resume(); + await within(once(response, "end")); + client.destroy(); + const request = await within(closed); + request.push(form.slice(10)); + request.push(null); + + equal(typeof (await within(settled)), "undefined"); + deepEqual([...(request.body as FormData)], [["a", "1"]]); + }); + + it("keeps no listener on a connection that is kept alive", async (t) => { + const counts: number[] = []; + const sockets = new Set(); + const parser = new Multipart(); + const { url } = await serve(t, [ + async (context: Context): Promise => { + await parser.run(context); + const { socket } = context.request; + sockets.add(socket); + counts.push(socket.listenerCount("close")); + await context.send(); + }, + ]); + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + t.after(() => { + agent.destroy(); + }); + + for (let index = 0; index < 3; index += 1) { + const reply = await send(url, { + headers: { "Content-Type": type }, + body: body(part('name="a"', "1")), + agent, + }); + equal(reply.status, 200); + } + + deepEqual([sockets.size, counts.length, new Set(counts).size], [1, 3, 1]); + }); +}); + +describe("Multipart (a parse that starts once the response has ended)", () => { + const discarded = "The body has been discarded once the response finished"; + + it("refuses the body once the response has finished", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const logger = new RecordingLogger(); + const parser = new Multipart(); + const { url } = await serve( + t, + [ + async (context: Context): Promise => { + // Without `done`: the next middleware runs + await context.response.status(202).send("accepted"); + }, + async (context: Context): Promise => { + if (!context.response.writableFinished) { + await once(context.response, "finish"); + } + await parser.run(context).then(settle, settle); + }, + ], + { logger }, + ); + const form = body(part('name="a"', "1")); + + // The body once the client has the reply + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": form.length }, + }); + client.on("error", ignore); + client.flushHeaders(); + const [response] = (await within(once(client, "response"))) as [ + IncomingMessage, + ]; + response.resume(); + await within(once(response, "end")); + client.end(form); + const error = await within(settled); + + ok(error instanceof BindenError); + deepEqual([error.status, error.message], [500, discarded]); + ok( + logger.records.some( + ({ level, message }) => level === "error" && message === discarded, + ), + ); + }); + + it( + "parses the body once the response has ended, but not finished", + { + // Windows takes a whole write while its send buffer has room: the + // response may finish before the client reads it + skip: process.platform === "win32", + }, + async (t) => { + // Larger than what the connection buffers: the response cannot finish + // before the client reads it + const large = Buffer.alloc(32 * 1024 * 1024, 0x61); + const states: boolean[] = []; + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: started, resolve: start } = + Promise.withResolvers(); + const parser = new Multipart(); + const { url } = await serve(t, [ + (context: Context): void => { + context.response.end(large); + }, + async (context: Context): Promise => { + const { response } = context; + states.push(response.writableEnded, response.writableFinished); + start(null); + await parser.run(context).then(() => { + settle([...(context.request.body as FormData)]); + }, settle); + }, + ]); + const form = body(part('name="a"', "1")); + + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": form.length }, + }); + client.on("error", ignore); + // Listened to at once: the reply arrives before the body is sent, and + // Node.js dumps a response that nothing listens to (which would also + // let the reply finish) + const responded = once(client, "response"); + client.flushHeaders(); + await within(started); + client.end(form); + + deepEqual(await within(settled), [["a", "1"]]); + deepEqual(states, [true, false]); + const [response] = (await within(responded)) as [IncomingMessage]; + response.resume(); + // 32 MiB through the loopback: slower on a loaded machine + await within(once(response, "end"), 30_000); + }, + ); +}); diff --git a/packages/multipart/test/disk-store.test.ts b/packages/multipart/test/disk-store.test.ts new file mode 100644 index 00000000..f97b3eea --- /dev/null +++ b/packages/multipart/test/disk-store.test.ts @@ -0,0 +1,628 @@ +import { + DiskStore, + type IFileSystem, + StoredFile, + storedForm, +} from "../src/disk.js"; +import { PassThrough, Readable } from "node:stream"; +import { type ReadStream, constants } from "node:fs"; +import { type TestContext, describe, it } from "node:test"; +import { deepEqual, equal, ok, rejects, throws } from "node:assert/strict"; +import { eventually, never, sleep, within } from "./helpers.js"; +import fs, { + type FileHandle, + lstat, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +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), + lstat: (path, options) => fs.lstat(path, options), + unlink: (path) => fs.unlink(path), + rmdir: (path) => fs.rmdir(path), + ...overrides, + }; +} + +const posix = process.platform !== "win32"; + +/** The refusal of a file once the cleanup has started */ +const refusal = { + status: 500, + message: + "A file cannot be stored once the cleanup of the request has started", +}; + +/** 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 where there cannot be a hard link", async (t) => { + for (const code of ["EXDEV", "EPERM", "ENOTSUP", "EMLINK"]) { + const { root, store } = await create(t, { + link: () => Promise.reject(failure(code)), + }); + 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(); + + const { mode, nlink } = await stat(target); + deepEqual( + [ + await readdir(root), + await readFile(target, "utf8"), + posix ? mode & 0o777 : 0o600, + nlink, + ], + [["kept.txt"], "Hello", 0o600, 1], + ); + ok(temp !== target); + } + }); + + it("throws the other errors of a hard link, without a copy", async (t) => { + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EACCES")), + }); + + const file = store.add(info, content("Hello")); + await rejects(file.persist(join(root, "kept.txt")), failure("EACCES")); + + equal((await readdir(root)).includes("kept.txt"), false); + }); + + 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")), + open: async (path, flags, mode) => { + const handle = await fs.open(path, flags, mode); + if (flags === constants.O_RDONLY && (copies += 1) === 1) { + // Fails once a part has been written + handle.createReadStream = (): ReadStream => + Readable.from( + (async function* chunks(): AsyncGenerator { + yield Buffer.from("Hel"); + await sleep(10); + throw failure("EIO"); + })(), + ) as ReadStream; + } + return handle; + }, + }); + const target = join(root, "kept.txt"); + + const file = store.add(info, content("Hello")); + await rejects(file.persist(target), failure("EIO")); + equal((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("never removes a target that it has not created, also when the file is gone", async (t) => { + let temp = ""; + const { root, store } = await create(t, { + // The temporary file disappears before the copy + link: async () => { + await fs.unlink(temp); + throw failure("EXDEV"); + }, + }); + const existing = join(root, "existing.txt"); + await writeFile(existing, "Existing"); + + for (const target of [existing, join(root, "new.txt")]) { + const file = store.add(info, content("Hello")); + await file.written; + temp = file.path; + await rejects(file.persist(target), { + code: target === existing ? "EEXIST" : "ENOENT", + }); + } + + deepEqual( + [ + (await readdir(root)).filter((name) => name.endsWith(".txt")), + await readFile(existing, "utf8"), + ], + [["existing.txt"], "Existing"], + ); + }); + + it( + "never removes a file or a link that has replaced its target during a failed copy", + // Windows refuses to rename a file that is open: the replacement cannot be staged + { skip: process.platform === "win32" }, + async (t) => { + const replacements = posix ? ["file", "link"] : ["file"]; + for (const replacement of replacements) { + let target = ""; + let moved = ""; + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + open: async (path, flags, mode) => { + if (flags === constants.O_RDONLY) { + // Another process replaces the file of the copy, which then fails + if (replacement === "file") { + await writeFile(moved, "Another"); + await rename(moved, target); + } else { + await rename(target, moved); + await symlink(moved, target); + } + throw failure("EIO"); + } + return fs.open(path, flags, mode); + }, + }); + target = join(root, "kept.txt"); + moved = join(root, "moved.txt"); + + const file = store.add(info, content("Hello")); + await rejects(file.persist(target), failure("EIO")); + + ok( + replacement === "file" + ? (await readFile(target, "utf8")) === "Another" + : (await lstat(target)).isSymbolicLink(), + replacement, + ); + } + }, + ); + + it("closes the descriptors of a copy, whether it succeeds or fails", async (t) => { + const handles: FileHandle[] = []; + let fail = ""; + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + open: async (path, flags, mode) => { + if (fail === "open" && flags === constants.O_RDONLY) { + throw failure("EMFILE"); + } + const handle = await fs.open(path, flags, mode); + handles.push(handle); + if (fail === "read" && flags === constants.O_RDONLY) { + handle.createReadStream = (): ReadStream => + Readable.from( + (async function* chunks(): AsyncGenerator { + yield Buffer.from("Hel"); + await sleep(10); + throw failure("EIO"); + })(), + ) as ReadStream; + } + return handle; + }, + }); + + for (const failing of ["", "read", "open"]) { + fail = failing; + const file = store.add(info, content("Hello")); + await file.persist(join(root, `${failing}.txt`)).catch(() => null); + } + + // The temporary files, and for each copy its target and (once it can be + // opened) its source + equal(handles.length, 8); + deepEqual( + handles.map(({ fd }) => fd), + handles.map(() => -1), + ); + }); + + it("fails a copy that cannot close its file, and removes it", async (t) => { + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + open: async (path, flags, mode) => { + const handle = await fs.open(path, flags, mode); + if (path.endsWith("kept.txt")) { + const close = handle.close.bind(handle); + handle.close = async (): Promise => { + await close(); + throw failure("EIO"); + }; + } + return handle; + }, + }); + + const file = store.add(info, content("Hello")); + await rejects(file.persist(join(root, "kept.txt")), failure("EIO")); + + equal((await readdir(root)).includes("kept.txt"), false); + }); + + it("closes and leaves the file of a copy that cannot tell what it has created", async (t) => { + const handles: FileHandle[] = []; + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + open: async (path, flags, mode) => { + const handle = await fs.open(path, flags, mode); + if (path.endsWith("kept.txt")) { + handles.push(handle); + handle.stat = (): Promise => Promise.reject(failure("EIO")); + } + return handle; + }, + }); + const target = join(root, "kept.txt"); + + const file = store.add(info, content("Hello")); + await rejects(file.persist(target), failure("EIO")); + + // Without its identity, it cannot be told from a file that has replaced + // it: the empty file that the copy has created is left + deepEqual( + [handles.length, handles[0]?.fd, (await stat(target)).size], + [1, -1, 0], + ); + equal(await readFile(file.path, "utf8"), "Hello"); + }); + + 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")), + open: async (path, flags, mode) => { + if (flags === constants.O_RDONLY) { + throw failure("EIO"); + } + return fs.open(path, flags, mode); + }, + 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("EIO")); + }); + + 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")), refusal); + 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, refusal); + 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, refusal); + 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()); + }); +}); + +/** + * The steps of the iterators of arrays while `fn` runs: a spread copies an + * array with them, one step for each element + */ +function arraySteps(fn: () => void): number { + const prototype = Object.getPrototypeOf([][Symbol.iterator]()) as Record< + "next", + (...args: unknown[]) => unknown + >; + const original = prototype.next; + let steps = 0; + prototype.next = function next(this: unknown, ...args: unknown[]): unknown { + steps += 1; + return original.apply(this, args); + }; + try { + fn(); + } finally { + prototype.next = original; + } + return steps; +} + +describe("storedForm()", () => { + it("collects the fields and the stored files by name, in their order", () => { + const file = new StoredFile(info, system(), never(), content("")); + const other = new StoredFile(info, system(), never(), content("")); + + const { fields, files } = storedForm([ + { name: "a", value: "1" }, + { name: "file", value: file }, + { name: "skipped", value: null }, + { name: "a", value: "2" }, + { name: "b", value: "3" }, + { name: "file", value: other }, + ]); + + deepEqual( + [[...fields], [...files.keys()], files.get("file")], + [ + [ + ["a", ["1", "2"]], + ["b", ["3"]], + ], + ["file"], + [file, other], + ], + ); + }); + + it("takes a time linear in the count of the parts of a name", () => { + const count = 2000; + const file = new StoredFile(info, system(), never(), content("")); + const parts = Array.from({ length: count }, (_, index) => ({ + name: index % 2 === 0 ? "field" : "file", + value: index % 2 === 0 ? String(index) : file, + })); + + let form = storedForm([]); + const steps = arraySteps(() => { + form = storedForm(parts); + }); + + // One step for each part, by the loop over them: a copy of the values + // of a name for each value would take a million more + ok(steps <= 2 * count, `${steps} steps`); + deepEqual( + [form.fields.get("field")?.length, form.files.get("file")?.length], + [count / 2, count / 2], + ); + }); +}); diff --git a/packages/multipart/test/disk.test.ts b/packages/multipart/test/disk.test.ts new file mode 100644 index 00000000..3f215575 --- /dev/null +++ b/packages/multipart/test/disk.test.ts @@ -0,0 +1,473 @@ +import { Agent, type IncomingMessage, request as httpRequest } from "node:http"; +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 { once } from "node:events"; +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("stores a body received before the cleanup, refuses the files that come after it", async (t) => { + const dir = await directory(t); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: cleaned, resolve: clean } = Promise.withResolvers(); + const parser = multipart({ storage: { dir } }); + const { url } = await serve(t, [ + async (context: Context): Promise => { + // Not awaited: the response is closed before the body is received + parser.run(context).then(settle, settle); + context.onCleanup(() => { + clean(null); + }); + await context.send("early"); + }, + ]); + + // Kept alive: the server reads the rest of the body after its reply + const agent = new Agent({ keepAlive: true }); + t.after(() => { + agent.destroy(); + }); + const client = httpRequest(url, { + method: "POST", + agent, + headers: { "Content-Type": type, "Content-Length": form.length }, + }); + client.on("error", () => { + // The test closes the connection + }); + client.flushHeaders(); + const [response] = (await within(once(client, "response"))) as [ + IncomingMessage, + ]; + response.resume(); + await within(cleaned); + // The whole body, once the cleanup of the request has started + client.end(form); + const error = await within(settled); + client.destroy(); + + ok(error instanceof BindenError); + deepEqual( + [error.status, error.message, (error.cause as Error).message], + [ + 500, + "A file cannot be stored once the cleanup of the request has started", + "The request has been completed", + ], + ); + deepEqual(await readdir(dir), []); + }); + + 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(10_000), + }); + 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/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/filter.test.ts b/packages/multipart/test/filter.test.ts new file mode 100644 index 00000000..01b1022a --- /dev/null +++ b/packages/multipart/test/filter.test.ts @@ -0,0 +1,253 @@ +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(10_000), + }); + 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 BindenAbortError); + equal(error.reason, "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-context.ts b/packages/multipart/test/fixtures/late-context.ts new file mode 100644 index 00000000..ea251547 --- /dev/null +++ b/packages/multipart/test/fixtures/late-context.ts @@ -0,0 +1,110 @@ +// Run by `process.test.ts`, without handlers of the process: what a context +// that only looks like one throws once the parse has started (its response), +// and a `file_filter` that throws a value whose check throws, must be the +// rejection of the parse, which settles: never a pending parse, an uncaught +// exception or an `unhandledRejection` +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"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +/** A proxy of the context whose response has a `setHeader()` that throws */ +function ducked(context: Context): Context { + const response = new Proxy(context.response, { + get(object, key): unknown { + if (key === "setHeader") { + return (): never => { + throw new Error("setHeader of the context"); + }; + } + const value: unknown = Reflect.get(object, key, object); + return typeof value === "function" + ? (value as () => unknown).bind(object) + : value; + }, + }); + return new Proxy(context, { + get(object, key): unknown { + return key === "response" + ? response + : (Reflect.get(object, key, object) as unknown); + }, + }); +} + +const { proxy, revoke } = Proxy.revocable({}, {}); +revoke(); +const thrown = proxy as Error; +const parsers: Record Context]> = { + "/response": [new Multipart({ config: { limits: { fileSize: 2 } } }), ducked], + "/file-filter": [ + new Multipart({ + file_filter: (): never => { + throw thrown; + }, + }), + (context): Context => context, + ], +}; + +const settled: string[] = []; +const app = new Binden().use(async (context: Context) => { + const [parser, duck] = parsers[String(context.request.url)] as [ + Multipart, + (context: Context) => Context, + ]; + try { + await parser.run(duck(context)); + settled.push("resolved"); + } catch (error: unknown) { + settled.push((error as Error).message); + } + await context.send("done"); +}); + +const server = app.createServer().listen(0, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const boundary = "binden-boundary"; +const body = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="f"; filename="f.txt"', + "Content-Type: text/plain", + "", + "abcdefgh", + `--${boundary}--`, + "", +].join("\r\n"); + +const statuses: number[] = []; +for (const path of Object.keys(parsers)) { + const sent = request({ + agent: false, + host: "127.0.0.1", + port, + path, + method: "POST", + headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` }, + }); + sent.on("error", () => { + // The connection may close before the body is sent + }); + const response = new Promise((resolve) => { + sent.once("response", resolve); + }); + sent.end(body); + const received = await response; + received.resume(); + await once(received, "end"); + statuses.push(received.statusCode ?? 0); +} + +server.close(); +process.stdout.write(`${statuses.join(" ")} | ${settled.join(" | ")}\n`); diff --git a/packages/multipart/test/fixtures/late-rejections.ts b/packages/multipart/test/fixtures/late-rejections.ts new file mode 100644 index 00000000..57d1bb0b --- /dev/null +++ b/packages/multipart/test/fixtures/late-rejections.ts @@ -0,0 +1,79 @@ +// 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("/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, "127.0.0.1"); +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", + "/on-file-hangs", + "/on-file-fails-late", +]) { + const response = await new Promise((resolve, reject) => { + request({ + agent: false, + method: "POST", + path, + host: "127.0.0.1", + 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/fixtures/stream-memory.ts b/packages/multipart/test/fixtures/stream-memory.ts new file mode 100644 index 00000000..9ab6f2db --- /dev/null +++ b/packages/multipart/test/fixtures/stream-memory.ts @@ -0,0 +1,76 @@ +// 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, "127.0.0.1"); +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", + host: "127.0.0.1", + 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/formatted.test.ts b/packages/multipart/test/formatted.test.ts new file mode 100644 index 00000000..f152a89f --- /dev/null +++ b/packages/multipart/test/formatted.test.ts @@ -0,0 +1,142 @@ +import { Multipart, MultipartFile, multipartInfo } from "../index.js"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import type { Context } from "binden"; + +/** An error of the application's own (e.g. of a getter, or of a trap) */ +class OwnError extends Error {} + +/** What a call has thrown (or rejected with), and whether it was formatted then */ +interface IOutcome { + readonly thrown: unknown; + readonly formatted: boolean; +} + +/** + * Runs a call while `Error.prepareStackTrace` records each error whose stack + * V8 formats: what it throws (or its promise rejects with), and whether that + * was formatted before the caller got it (`null` when it does not fail) + */ +async function outcome(run: () => unknown): Promise { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + try { + await run(); + return null; + } catch (thrown: unknown) { + return { + thrown, + formatted: typeof thrown === "object" && formatted.has(thrown as object), + }; + } finally { + Reflect.set(Error, "prepareStackTrace", previous); + } +} + +/** A revoked proxy */ +function revoked(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +suite( + "The entry points of the multipart parser format what they throw (round 27)", + () => { + test("new Multipart(), run() and multipartInfo() format what reading their argument throws", async () => { + for (const run of [ + (): unknown => new Multipart(revoked()), + (): unknown => new Multipart().run(null as unknown as Context), + (): unknown => new Multipart().run(revoked() as Context), + (): unknown => multipartInfo(null as unknown as Context), + (): unknown => multipartInfo(revoked() as Context), + ]) { + const failed = await outcome(run); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + } + const own = new OwnError("own"); + deepStrictEqual( + await outcome( + () => + new Multipart({ + get limit(): number { + throw own; + }, + }), + ), + { thrown: own, formatted: true }, + ); + }); + + test("new MultipartFile() formats the errors of File and what reading its options throws, and keeps `truncated` as given", async () => { + for (const args of [ + [null, "a.txt"], + [["a"], Symbol("s")], + [["a"], "a.txt", revoked()], + [["a"], "a.txt", null], + ]) { + const failed = await outcome( + () => new MultipartFile(...(args as [BlobPart[], string])), + ); + ok(failed?.thrown instanceof TypeError); + ok(failed.formatted); + } + const own = new OwnError("own"); + deepStrictEqual( + await outcome( + () => + new MultipartFile(["a"], "a.txt", { + get truncated(): boolean { + throw own; + }, + }), + ), + { thrown: own, formatted: true }, + ); + deepStrictEqual( + [ + new MultipartFile(["a"], "a.txt").truncated, + new MultipartFile(["a"], "a.txt", { truncated: true }).truncated, + new MultipartFile(["a"], "a.txt", { type: "text/plain" }).type, + ], + [false, true, "text/plain"], + ); + }); + + test("new MultipartFile() refuses a `truncated` that is not a boolean, described without converting it, before File reads the content (R28)", async () => { + let conversions = 0; + const counting = { + [Symbol.toPrimitive]: (): boolean => { + conversions += 1; + return true; + }, + }; + for (const [truncated, message] of [ + [Symbol("s"), "`truncated` is not a boolean: (a symbol)"], + [1, "`truncated` is not a boolean: 1"], + ["true", "`truncated` is not a boolean: true"], + [null, "`truncated` is not a boolean: null"], + [counting, "`truncated` is not a boolean: (an object)"], + ] as const) { + // Content that File refuses: the option is refused first + const failed = await outcome( + () => + new MultipartFile(null as unknown as BlobPart[], "a.txt", { + truncated: truncated as unknown as boolean, + }), + ); + ok(failed?.thrown instanceof TypeError); + deepStrictEqual( + [failed.thrown.message, failed.formatted], + [message, true], + ); + } + deepStrictEqual(conversions, 0); + }); + }, +); diff --git a/packages/multipart/test/helpers.ts b/packages/multipart/test/helpers.ts new file mode 100644 index 00000000..1ec5767f --- /dev/null +++ b/packages/multipart/test/helpers.ts @@ -0,0 +1,310 @@ +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 127.0.0.1 (never `::`, see binden) */ +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, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${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. The + * default only guards against a hang, with a deadline that a loaded machine + * does not reach: a test that asserts a timing passes its own + */ +export async function within(promise: Promise, ms = 10_000): 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); + } +} + +/** + * Resolves once `check()` is true, which it must be within `ms` milliseconds + * (10 s by default: a guard against a hang), judged after a turn of the + * pending I/O (a 5 ms timer, then two turns of `setImmediate()`: after a + * stall of the process, the timers run before the I/O that has completed + * meanwhile, which the system may not have delivered yet, e.g. the end of a + * loopback connection on macOS, and the `close` of a socket whose end is + * read then is emitted after the first turn) + */ +export async function eventually( + check: () => Promise | boolean, + ms = 10_000, +): Promise { + const deadline = Date.now() + ms; + while (!(await check())) { + if (Date.now() > deadline) { + await sleep(5); + for (let turn = 0; turn < 2; turn += 1) { + await new Promise((resolve) => { + setImmediate(resolve); + }); + } + if (await check()) { + return; + } + throw new Error(`Not true within ${ms} ms`); + } + await sleep(5); + } +} + +/** 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(10_000); + 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(10_000), + }); + 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/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], + ]); + }); +}); diff --git a/packages/multipart/test/late-calls.test.ts b/packages/multipart/test/late-calls.test.ts new file mode 100644 index 00000000..a8718f46 --- /dev/null +++ b/packages/multipart/test/late-calls.test.ts @@ -0,0 +1,389 @@ +import { Binden, BindenError, type Context } from "binden"; +import { + type ITestServer, + body, + listen, + part, + type, + within, +} from "./helpers.js"; +import { type IncomingMessage, request } from "node:http"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { getEventListeners, once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { suite, test } from "node:test"; +import { Multipart } from "../index.js"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** An error of the application's own (e.g. of a getter, or of a method) */ +class OwnError extends Error {} + +/** How a parse has settled: its error, and whether it was formatted once `run()` rejected */ +type ISettled = + | { readonly error: unknown; readonly formatted: boolean } + | { readonly ok: true }; + +/** The getters that replace properties of a proxy (see `ducked()`) */ +type IReplaced = Readonly unknown>>; + +/** + * A proxy that only looks like `target`: it reads the properties of the + * target (its methods bound to it) and sets them on it, but for the getters + * of `replaced`, which may succeed first and throw later + */ +function ducked(target: T, replaced: IReplaced): T { + return new Proxy(target, { + get(object, key): unknown { + if (typeof key === "string" && Object.hasOwn(replaced, key)) { + return replaced[key](); + } + const value: unknown = Reflect.get(object, key, object); + return typeof value === "function" + ? (value as (...args: unknown[]) => unknown).bind(object) + : value; + }, + set(object, key, value): boolean { + return Reflect.set(object, key, value, object); + }, + }); +} + +/** + * A function (a getter, a setter or a method) that throws a new error of its + * own, kept in `errors` + */ +function failing(errors: OwnError[], name: string): () => never { + return (): never => { + const error = new OwnError(`${name} of the context`); + errors.push(error); + throw error; + }; +} + +/** The getter of a method (see `ducked()`) */ +function method(call: () => unknown): () => unknown { + return () => call; +} + +/** + * Serves a parser that runs on a context that only looks like the one of the + * request (made by `duck`), then replies `parsed`: how it settles, while + * `Error.prepareStackTrace` records the errors whose stacks V8 formats + */ +async function serveDucked( + parser: Multipart, + duck: (context: Context) => Context, +): Promise<{ + server: ITestServer; + started: Promise; + settled: Promise; + restore(): void; +}> { + const formatted = new WeakSet(); + const previous: unknown = Reflect.get(Error, "prepareStackTrace"); + Reflect.set(Error, "prepareStackTrace", (error: Error): string => { + formatted.add(error); + return `${error.name}: ${error.message}`; + }); + const { promise: started, resolve: start } = Promise.withResolvers(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const server = await listen( + new Binden().use(async (context: Context) => { + const parsing = parser.run(duck(context)); + start(context); + try { + await parsing; + settle({ ok: true }); + } catch (error: unknown) { + settle({ error, formatted: formatted.has(error as object) }); + throw error; + } + await context.send("parsed"); + }), + ); + return { + server, + started, + settled, + restore: (): void => { + Reflect.set(Error, "prepareStackTrace", previous); + }, + }; +} + +/** Sends the headers and the first chunk of a body, without ending it */ +function upload( + server: ITestServer, + chunk: string, + length = chunk.length, +): { req: ReturnType; response: Promise } { + const req = request(server.url, { + method: "POST", + agent: false, + headers: { "Content-Type": type, "Content-Length": length }, + }); + 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.on("error", () => { + // The server may close the connection before the body is sent + }); + req.write(chunk); + return { req, response }; +} + +/** The status of the reply to a request */ +async function status(response: Promise): Promise { + const received = await within(response); + received.resume(); + await within(once(received, "end")); + return received.statusCode ?? 0; +} + +/** A form of a field and a file */ +const form = body( + part('name="a"', "1"), + part('name="f"; filename="f.txt"', "abcdefgh", ["Content-Type: text/plain"]), +); + +suite("Multipart (late calls of a context)", () => { + test("a context whose getters throw from their second read: each is read once", async () => { + for (const [parser, length, expected] of [ + [new Multipart(), form.length, 200], + [new Multipart({ limit: 4 }), form.length, 413], + [ + new Multipart({ config: { limits: { fileSize: 2 } } }), + form.length, + 413, + ], + ] as const) { + const reads = new Map(); + const served = await serveDucked(parser, (context) => + ducked( + context, + Object.fromEntries( + ["request", "response", "signal", "log", "onCleanup"].map((key) => [ + key, + (): unknown => { + const count = (reads.get(key) ?? 0) + 1; + reads.set(key, count); + if (count > 1) { + throw new OwnError(`${key} read again`); + } + return Reflect.get(context, key, context); + }, + ]), + ), + ), + ); + try { + const { req, response } = upload(served.server, form, length); + const context = await within(served.started); + req.end(); + deepStrictEqual(await status(response), expected); + const settled = await within(served.settled); + if (expected === 200) { + deepStrictEqual(settled, { ok: true }); + ok(context.request.body instanceof FormData); + deepStrictEqual(context.request.body.get("a"), "1"); + } else { + ok("error" in settled && settled.error instanceof BindenError); + deepStrictEqual(settled.error.status, 413); + } + ok([...reads.values()].every((count) => count === 1)); + } finally { + served.restore(); + await served.server.close(); + } + } + }); + + test("a response whose setHeader() throws once a file is over its limit: its error is the rejection, formatted, and the request is unpiped", async () => { + const errors: OwnError[] = []; + const served = await serveDucked( + new Multipart({ config: { limits: { fileSize: 2 } } }), + (context) => + ducked(context, { + response: () => + ducked(context.response, { + setHeader: method(failing(errors, "setHeader")), + }), + }), + ); + try { + const { req, response } = upload(served.server, form, form.length + 10); + const context = await within(served.started); + const settled = await within(served.settled); + deepStrictEqual( + [settled, context.request.readableFlowing], + [{ error: errors[0], formatted: true }, false], + ); + req.destroy(); + await status(response).catch(() => 0); + } finally { + served.restore(); + await served.server.close(); + } + }); + + test("a signal whose reason throws once it is aborted, then a setHeader() and a logger that throw: the first failure is the rejection", async () => { + const errors: OwnError[] = []; + const controller = new AbortController(); + const logger = { + fatal: (): null => null, + error: (): null => null, + warn: (): null => null, + info: (): null => null, + debug: failing(errors, "debug"), + trace: (): null => null, + child: (): unknown => logger, + }; + const served = await serveDucked(new Multipart(), (context) => + ducked(context, { + signal: () => + ducked(controller.signal, { reason: failing(errors, "reason") }), + response: () => + ducked(context.response, { + setHeader: method(failing(errors, "setHeader")), + }), + log: () => logger, + }), + ); + try { + const { req, response } = upload(served.server, "--", form.length); + await within(served.started); + controller.abort(); + const settled = await within(served.settled); + deepStrictEqual( + [settled, errors.map(({ message }) => message)], + [ + { error: errors[0], formatted: true }, + [ + "reason of the context", + "setHeader of the context", + "debug of the context", + ], + ], + ); + req.destroy(); + await status(response).catch(() => 0); + } finally { + served.restore(); + await served.server.close(); + } + }); + + test("a request whose `body` cannot be set, or whose methods throw as the parse starts: the rejection, formatted, and no listener is left", async () => { + for (const name of ["body", "pipe"]) { + const errors: OwnError[] = []; + const controller = new AbortController(); + const served = await serveDucked(new Multipart(), (context) => + ducked(context, { + signal: () => controller.signal, + request: (): unknown => { + if (name === "body") { + return new Proxy(ducked(context.request, {}), { + set: failing(errors, "body"), + }); + } + return ducked(context.request, { + pipe: method(failing(errors, "pipe")), + }); + }, + }), + ); + try { + const { req, response } = upload(served.server, form); + await within(served.started); + req.end(); + deepStrictEqual(await within(served.settled), { + error: errors[0], + formatted: true, + }); + deepStrictEqual( + getEventListeners(controller.signal, "abort").length, + 0, + ); + deepStrictEqual(await status(response), 500); + } finally { + served.restore(); + await served.server.close(); + } + } + }); + + test("the disk storage: a context whose onCleanup is read with an error is refused with it, one whose onCleanup() throws with a 500", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "binden-late-")); + t.after(() => rm(dir, { recursive: true, force: true })); + for (const name of ["read", "call"]) { + const errors: OwnError[] = []; + const served = await serveDucked( + new Multipart({ storage: { dir } }), + (context) => + ducked(context, { + onCleanup: + name === "read" + ? failing(errors, "onCleanup") + : method(failing(errors, "onCleanup")), + }), + ); + try { + const { req, response } = upload(served.server, form); + await within(served.started); + req.end(); + const settled = await within(served.settled); + if (name === "read") { + deepStrictEqual(settled, { error: errors[0], formatted: true }); + } else { + ok("error" in settled && settled.error instanceof BindenError); + deepStrictEqual( + [settled.error.status, settled.error.cause], + [500, errors[0]], + ); + } + deepStrictEqual(await status(response), 500); + } finally { + served.restore(); + await served.server.close(); + } + } + }); + + test("a file_filter that throws a value whose check throws (a revoked proxy): the rejection, never a pending parse", async () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + const thrown = proxy as Error; + const served = await serveDucked( + new Multipart({ + file_filter: (): never => { + throw thrown; + }, + }), + (context) => context, + ); + try { + const { req, response } = upload(served.server, form); + await within(served.started); + req.end(); + const settled = await within(served.settled); + ok("error" in settled && settled.error instanceof TypeError); + deepStrictEqual( + [settled.error.message, settled.formatted], + [ + "Cannot perform 'getPrototypeOf' on a proxy that has been revoked", + true, + ], + ); + deepStrictEqual(await status(response), 500); + } finally { + served.restore(); + await served.server.close(); + } + }); +}); diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts new file mode 100644 index 00000000..1f85fcee --- /dev/null +++ b/packages/multipart/test/multipart.test.ts @@ -0,0 +1,735 @@ +import { Agent, request as httpRequest } from "node:http"; +import { Binden, BindenError, Context } from "binden"; +import { + RecordingLogger, + body, + encode, + listen, + part, + post, + send, + serve, + type, + upload, +} from "./helpers.js"; +import { deepEqual, equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import multipart, { Multipart } from "../index.js"; +import { PassThrough } from "node:stream"; +import { gzipSync } from "node:zlib"; + +/** 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", () => { + 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("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"`, + }); + + equal(reply.status, 200); + deepEqual(parsed, [["field", "value"]]); + }); + + it('names a part without a name `""`', async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [multipart(), keep(parsed)]); + + const reply = await post( + url, + body(part('name=""', "value"), part('filename="a.txt"', "file")), + ); + + equal(reply.status, 200); + deepEqual(parsed, [ + ["", "value"], + ["", "a.txt", "text/plain", "file"], + ]); + }); + + 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(); + }, + ]); + + 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 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 a request that has already been parsed", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart(), + new Multipart(), + keep(parsed), + ]); + + const reply = await post(url, body(part('name="field"', "value"))); + + equal(reply.status, 200); + deepEqual(parsed, [["field", "value"]]); + }); + + 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, + }); + 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), + }); + equal(reply.status, 200); + deepEqual(parsed, [["field", "value"]]); + }); + + it("replies `400` to a malformed body", async (t) => { + let reached = false; + const { url } = await serve(t, [ + multipart(), + (): void => { + reached = true; + }, + ]); + + 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 `415` to a body with a content coding, which it does not decode", async (t) => { + const files: string[] = []; + let reached = false; + const { url } = await serve(t, [ + multipart({ + file_filter: ({ filename }): boolean => { + files.push(filename); + return true; + }, + }), + (): void => { + reached = true; + }, + ]); + const data = body(part('name="file"; filename="a.txt"', "Hello")); + + for (const [coding, sent] of [ + ["unknown", data], + ["gzip", gzipSync(data)], + ["identity, gzip", gzipSync(data)], + ] as const) { + const reply = await post(url, sent, { "Content-Encoding": coding }); + deepEqual([reply.status, reply.headers.connection], [415, "close"]); + } + deepEqual([files, reached], [[], false]); + }); + + it("parses a body whose content coding is `identity`", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [multipart(), keep(parsed)]); + + for (const coding of ["identity", "Identity, ", ""]) { + const reply = await post(url, body(part('name="a"', "1")), { + "Content-Encoding": coding, + }); + equal(reply.status, 200); + } + deepEqual(parsed, [ + ["a", "1"], + ["a", "1"], + ["a", "1"], + ]); + }); + + it("replies `415` to a field of an unsupported charset", async (t) => { + const { url } = await serve(t, [multipart()]); + + const reply = await post( + url, + body( + part('name="field"', "value", [ + "Content-Type: text/plain; charset=koi8-r", + ]), + ), + ); + + deepEqual([reply.status, reply.headers.connection], [415, "close"]); + }); + + 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({ config: { limits: config } }), + (): void => { + reached = true; + }, + ]); + + const reply = await post(url, body(...parts)); + + deepEqual([reply.status, reply.headers.connection], [413, "close"]); + equal(reached, false); + }); + } + + 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), + ]); + + const reply = await post(url, body(part('name="a"', "abcdef"))); + + equal(reply.status, 200); + deepEqual(parsed, [["a", "abc"]]); + }); + + 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), + ]); + + const reply = await post( + url, + body(part('name="a"', "1"), part('name="b"', "2")), + ); + + equal(reply.status, 200); + deepEqual(parsed, [["a", "1"]]); + }); + + 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"), + ), + ); + + equal(reply.status, 200); + deepEqual(parsed, [["a", "a.txt", "text/plain", "1"]]); + }); + + it("truncates the files to `fileSize` (`throw_limits: false`)", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart({ + config: { limits: { fileSize: 3 } }, + throw_limits: false, + }), + keep(parsed), + ]); + + const reply = await post( + url, + body( + part('name="a"; filename="a.txt"', "abcdef"), + part('name="b"; filename="b.txt"', "ab"), + ), + ); + + equal(reply.status, 200); + deepEqual(parsed, [ + ["a", "a.txt", "text/plain", "abc"], + ["b", "b.txt", "text/plain", "ab"], + ]); + }); + + 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"), + ), + ); + + equal(reply.status, 200); + deepEqual(parsed, [ + ["file", "file.txt", "text/plain", "123"], + ["k1", "v1"], + ]); + }); + + 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(); + }); + 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 replies = [ + await send(url, { headers, body: small, agent }), + await send(url, { headers, body: large, agent }), + ].map(({ status, headers: { connection } }) => [status, connection]); + + deepEqual(replies, [ + [200, "keep-alive"], + [413, "close"], + ]); + deepEqual(parsed, [["field", "value"]]); + }); + + 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"), + ), + }); + + deepEqual([reply.status, reply.headers.connection], [413, "close"]); + equal(reached, false); + }); + + 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(10_000), + }); + client + .once("response", (response) => { + response.resume(); + resolve(response.statusCode); + client.destroy(); + }) + .once("error", reject) + .flushHeaders(); + }); + + equal(reply, 413); + equal(reached, false); + }); + + it("a logger of the context whose methods and `child()` are replaced by ones that throw: the parse settles as without them, and none of them runs", async (t) => { + const logger = new RecordingLogger(); + const calls: string[] = []; + const replace = (context: Context): void => { + for (const method of [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + "child", + ]) { + Object.assign(context.log, { + [method]: (): never => { + calls.push(method); + throw new Error("A replaced method"); + }, + }); + } + }; + const { url } = await serve( + t, + [ + replace, + multipart({ config: { limits: { fields: 1 } } }), + async (context: Context): Promise => { + const form = context.request.body; + await (form instanceof FormData + ? context.json(await entries(form)) + : context.send("skipped")); + }, + ], + { logger }, + ); + + const parsed = await post(url, body(part('name="a"', "1"))); + const refused = await post( + url, + body(part('name="a"', "1"), part('name="b"', "2")), + ); + const skipped = await send(url, { + headers: { "Content-Type": "text/plain" }, + }); + + deepEqual( + [parsed.status, parsed.body, refused.status, skipped.status, calls], + [200, '[["a","1"]]', 413, 200, []], + ); + // Through the private path of the logger of the context + ok( + logger.records.some( + ({ message, bindings }) => + message === "The multipart body has been refused" && + bindings.middleware === "Multipart", + ), + ); + }); + + it("a `log` getter of the context that throws (also of a subclass of `Context`): the parse settles as without it, and its records are dropped", async (t) => { + const logger = new RecordingLogger(); + let reads = 0; + class Unlogged extends Context { + // eslint-disable-next-line class-methods-use-this -- a getter that fails + public override get log(): Context["log"] { + reads += 1; + throw new Error("No logger"); + } + } + const changes = { + getter: (context: Context): void => { + Object.defineProperty(context, "log", { + get: (): never => { + reads += 1; + throw new Error("No logger"); + }, + }); + }, + // The next context + subclass: ({ request, response }: Context): Context => + new Unlogged({ request, response }), + }; + const outcomes: unknown[] = []; + for (const [name, change] of Object.entries(changes)) { + const { url } = await serve( + t, + [ + change, + multipart({ config: { limits: { fields: 1 } } }), + async (context: Context): Promise => { + const form = context.request.body; + await (form instanceof FormData + ? context.json(await entries(form)) + : context.send("skipped")); + }, + ], + { logger }, + ); + const parsed = await post(url, body(part('name="a"', "1"))); + const refused = await post( + url, + body(part('name="a"', "1"), part('name="b"', "2")), + ); + const skipped = await send(url, { + headers: { "Content-Type": "text/plain" }, + }); + outcomes.push([ + name, + parsed.status, + parsed.body, + refused.status, + skipped.status, + ]); + } + deepEqual(outcomes, [ + ["getter", 200, '[["a","1"]]', 413, 200], + ["subclass", 200, '[["a","1"]]', 413, 200], + ]); + ok(reads > 0); + // Through no logger: dropped + ok( + logger.records.every( + ({ message }) => message !== "The multipart body has been refused", + ), + ); + }); + + it("logs with the logger of the context, as `Multipart`", async (t) => { + const logger = new RecordingLogger(); + const { url } = await serve( + t, + [multipart({ config: { limits: { fields: 0 } } })], + { + logger, + }, + ); + + const reply = await post(url, body(part('name="a"', "1"))); + + 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 }], + ); + }); +}); + +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; + }); + }, + ]); + + // The body is never complete: the reply comes before it + const reply = await upload( + url, + part('name="file"; filename="file.bin"', "a".repeat(64)), + ); + + deepEqual(reply, [413, "close"]); + ok(failure instanceof BindenError); + equal(failure.status, 413); + }); + + it("keeps the request of a send whose headers are not sent, and closes its connection after the reply", async (t) => { + const outcomes: unknown[] = []; + const parser = new Multipart({ limit: 16 }); + const { url } = await serve(t, [ + async (context: Context): Promise => { + const stream = new PassThrough(); + const sending = context.send(stream); + await parser.run(context).catch((error: unknown) => { + outcomes.push( + (error as BindenError).status, + context.request.destroyed, + ); + }); + // Its first chunk once the parse has been refused + stream.end("replied"); + await sending; + }, + ]); + + const reply = await post(url, body(part('name="a"', "a".repeat(64)))); + + deepEqual( + [reply.status, reply.headers.connection, reply.body], + [200, "close", "replied"], + ); + deepEqual(outcomes, [413, false]); + }); + + it("destroys the request once the headers of its response are sent", 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"]); + }); +}); diff --git a/packages/multipart/test/options.test.ts b/packages/multipart/test/options.test.ts new file mode 100644 index 00000000..4f99e13d --- /dev/null +++ b/packages/multipart/test/options.test.ts @@ -0,0 +1,195 @@ +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"), + // Without the control characters (e.g. `\x84`) of latin1 + Buffer.from("файл.txt") + .toString("latin1") + .replaceAll(/\p{Cc}/gu, ""), + ], + ]); + }); + + 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"], + ]); + }); +}); diff --git a/packages/multipart/test/process.test.ts b/packages/multipart/test/process.test.ts new file mode 100644 index 00000000..32b9a124 --- /dev/null +++ b/packages/multipart/test/process.test.ts @@ -0,0 +1,61 @@ +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, + { flags = [], args = [] }: { flags?: string[]; args?: string[] } = {}, +): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + ...flags, + "--import", + "tsx", + join(import.meta.dirname, "fixtures", fixture), + ...args, + ], + { 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 503 503\n", + stderr: "", + }); + }); + + test("what a context that only looks like one throws later, and a value whose check throws, are the rejection, never a pending parse", () => { + deepStrictEqual(run("late-context.ts"), { + status: 0, + stdout: + "200 200 | setHeader of the context | Cannot perform 'getPrototypeOf' on a proxy that has been revoked\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/setup.ts b/packages/multipart/test/setup.ts new file mode 100644 index 00000000..cc981f01 --- /dev/null +++ b/packages/multipart/test/setup.ts @@ -0,0 +1,8 @@ +import { Agent, setGlobalDispatcher } from "undici"; +import http from "node:http"; + +// 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 })); diff --git a/packages/multipart/test/stream.test.ts b/packages/multipart/test/stream.test.ts new file mode 100644 index 00000000..d49d48ab --- /dev/null +++ b/packages/multipart/test/stream.test.ts @@ -0,0 +1,323 @@ +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(10_000), + }); + 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")], + [{ on_file: null }, new TypeError("`storage.on_file` is not a function")], + [ + { on_file: (): null => null, dir: "/tmp" }, + 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) { + throws(() => new Multipart({ storage } as IMultipartOptions), error); + } + }); +}); 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/acceptance/disconnect.ts b/scripts/acceptance/disconnect.ts new file mode 100644 index 00000000..c5b2de80 --- /dev/null +++ b/scripts/acceptance/disconnect.ts @@ -0,0 +1,343 @@ +// Clients that go away in the middle of a request: of a body (also while `verify` +// never settles), of a multipart body in each storage (also while `on_file` never +// settles), of a file sent by `sendFile()`, and of a compressed stream. Each time, +// the work settles (nothing stays pending), `context.signal` is aborted +// (`client_closed`), the cleanups run, and nothing is left: no temporary file, no +// open file, no source that is not closed. A multipart body that is still read +// once the response has finished settles too when its client goes away (the +// signal is no longer aborted then), and the stream of its file is destroyed. +import { Binden, type Context, Router } from "binden"; +import { + closed, + deadline, + eventually, + multipart, + openFiles, + outcome, + read, + reasonOf, + serve, + start, +} from "./helpers.js"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { BodyParser } from "@binden/body"; +import { Compression } from "@binden/compression"; +import { Multipart } from "@binden/multipart"; +import { Readable } from "node:stream"; +import { finished } from "node:stream/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +deadline(30_000); + +const dir = mkdtempSync(join(tmpdir(), "binden-acceptance-")); +process.once("exit", () => { + rmSync(dir, { recursive: true, force: true }); +}); +const uploads = join(dir, "uploads"); +const file = join(dir, "large.bin"); +writeFileSync(file, Buffer.alloc(4 * 1024 * 1024, "a")); +mkdirSync(uploads); + +/** What a scenario has observed once the cleanups of its request have run */ +interface IObserved { + outcome: string; + signal: string | null; +} + +/** A scenario: the server has reached the point where the client goes away */ +interface IScenario { + readonly reached: PromiseWithResolvers; + readonly observed: PromiseWithResolvers; + /** Extra observations (e.g. whether a source has been closed) */ + readonly extra: Record; +} + +const scenarios = new Map(); +function scenario(name: string): IScenario { + const created: IScenario = { + reached: Promise.withResolvers(), + observed: Promise.withResolvers(), + extra: {}, + }; + scenarios.set(name, created); + return created; +} + +/** + * A middleware that awaits `work` and, once the cleanups of the request run, + * reports how it has settled (it must settle: the cleanup waits for it) and the + * signal + */ +function observe( + name: string, + work: (context: Context, current: IScenario) => Promise, +): (context: Context) => Promise { + const current = scenario(name); + return async (context: Context): Promise => { + const settled = outcome(work(context, current)); + context.onCleanup(async () => { + current.observed.resolve({ + outcome: await settled, + signal: reasonOf(context.signal), + }); + }); + await settled; + }; +} + +/** A promise that never settles */ +const never = (): Promise => Promise.withResolvers().promise; + +const MiB = 1024 * 1024; +const app = new Binden({ timeout: 30_000 }) + .use( + "/body", + observe("body", (context, current) => { + current.reached.resolve(null); + return new BodyParser({ limit: MiB }).run(context); + }), + ) + .use( + "/verify", + observe("verify never settles", (context, current) => + new BodyParser({ + limit: MiB, + verify: (): Promise => { + current.reached.resolve(null); + return never(); + }, + }).run(context), + ), + ) + .use( + "/memory", + observe("multipart (memory)", (context, current) => { + current.reached.resolve(null); + return new Multipart({ limit: 10 * MiB }).run(context); + }), + ) + .use( + "/disk", + observe("multipart (disk)", (context, current) => + new Multipart({ + limit: 10 * MiB, + storage: { dir: uploads }, + file_filter: (): boolean => { + current.reached.resolve(null); + return true; + }, + }).run(context), + ), + ) + .use( + "/stream", + observe("multipart (stream, on_file never settles)", (context, current) => + new Multipart({ + limit: 10 * MiB, + storage: { + on_file: (): Promise => { + current.reached.resolve(null); + return never(); + }, + }, + }).run(context), + ), + ) + .use( + "/after-response", + observe("multipart after the response", (context, current) => { + const parsed = new Multipart({ + limit: 10 * MiB, + storage: { + on_file: ({ stream }): Promise => { + current.reached.resolve(null); + stream.resume(); + return finished(stream).then( + () => { + current.extra.stream = "ended"; + }, + () => { + current.extra.stream = stream.destroyed + ? "destroyed" + : "failed"; + }, + ); + }, + }, + }).run(context); + // Not awaited before the response: it finishes while the body is read + return Promise.all([context.send("early"), parsed]); + }), + ) + .use( + "/file", + observe("sendFile", (context, current) => { + current.reached.resolve(null); + return context.sendFile(file); + }), + ) + .use( + "/compressed", + new Compression(), + new Router().get( + observe("compression", (context, current) => { + function* source(): Generator { + try { + for (let line = 0; ; line += 1) { + yield `${String(line).padStart(8, "0")} ${"x".repeat(1000)}\n`; + current.reached.resolve(null); + } + } finally { + current.extra.source_closed = true; + } + } + return context.type("text/plain").send(Readable.from(source())); + }), + ), + ); + +const { server, port } = await serve(app); + +const form = new FormData(); +form.append("field", "value"); +form.append("file", new File([Buffer.alloc(256 * 1024, "b")], "file.bin")); +const upload = await multipart(form); + +/** The requests: the first bytes that the client sends, then it goes away */ +const requests: [string, string, Record, Buffer][] = [ + [ + "body", + "/body", + { "Content-Type": "application/json", "Content-Length": 1000 }, + Buffer.from('{"a":"'), + ], + [ + "verify never settles", + "/verify", + { "Content-Type": "application/json" }, + Buffer.from("{}"), + ], + ...(["/memory", "/disk", "/stream"] as const).map( + ( + path, + index, + ): [string, string, Record, Buffer] => [ + [ + "multipart (memory)", + "multipart (disk)", + "multipart (stream, on_file never settles)", + ][index] ?? "", + path, + { "Content-Type": upload.type, "Content-Length": upload.body.length }, + upload.body.subarray(0, 64 * 1024), + ], + ), +]; + +/** The count of the files in the private directories of the requests */ +const stored = (): number => + readdirSync(uploads).reduce( + (count, request) => count + readdirSync(join(uploads, request)).length, + 0, + ); + +const results: Record = {}; +for (const [name, path, headers, head] of requests) { + const current = scenarios.get(name); + if (typeof current === "undefined") { + throw new Error(`No scenario ${name}`); + } + const files = openFiles(); + const { client } = start(port, path, { method: "POST", headers }); + client.on("error", () => { + // The connection is closed on purpose + }); + if (name === "verify never settles") { + client.end(head); + } else { + client.write(head); + } + await current.reached.promise; + if (path === "/disk") { + // A part of the file is on the disk + await eventually(() => stored() > 0, "the file is stored"); + } + client.destroy(); + const observed = await current.observed.promise; + await closed(files, name); + results[name] = { ...observed, ...current.extra }; +} +await eventually( + () => readdirSync(uploads).length === 0, + "the private directories of the requests are removed", +); +results.uploads_left = readdirSync(uploads).length; + +// The response finishes while the multipart body is read: the client reads it, +// then goes away in the middle of its file +{ + const name = "multipart after the response"; + const current = scenarios.get(name); + if (typeof current === "undefined") { + throw new Error(`No scenario ${name}`); + } + const files = openFiles(); + const { client, response } = start(port, "/after-response", { + method: "POST", + headers: { + "Content-Type": upload.type, + "Content-Length": upload.body.length, + }, + }); + client.on("error", () => { + // The connection is closed on purpose + }); + client.write(upload.body.subarray(0, 64 * 1024)); + const reply = await read(await response); + await current.reached.promise; + client.destroy(); + const observed = await current.observed.promise; + await eventually( + () => typeof current.extra.stream === "string", + "the stream of the file has settled", + ); + await closed(files, name); + results[name] = { status: reply.status, ...observed, ...current.extra }; +} + +// The downloads: the client reads the first bytes, then goes away +for (const [name, path, headers] of [ + ["sendFile", "/file", {}], + ["compression", "/compressed", { "Accept-Encoding": "gzip" }], +] as const) { + const current = scenarios.get(name); + if (typeof current === "undefined") { + throw new Error(`No scenario ${name}`); + } + const files = openFiles(); + const { client, response } = start(port, path, { headers }); + client.on("error", () => { + // The connection is closed on purpose + }); + client.end(); + const started = await response; + started.on("error", () => { + // The connection is closed on purpose + }); + await current.reached.promise; + current.extra.encoding = started.headers["content-encoding"] ?? null; + client.destroy(); + const observed = await current.observed.promise; + await closed(files, name); + results[name] = { ...observed, ...current.extra }; +} + +server.close(); +process.stdout.write(`${JSON.stringify(results)}\n`); 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/acceptance/helpers.ts b/scripts/acceptance/helpers.ts new file mode 100644 index 00000000..fb4e8258 --- /dev/null +++ b/scripts/acceptance/helpers.ts @@ -0,0 +1,202 @@ +// What the scenarios of the acceptance share. Each scenario is a process of its +// own (run by `scenarios.test.mjs`): it serves an application and is its own +// client, prints what it has observed as JSON, and exits by itself once nothing is +// left (an open handle is a timeout). A crash, an `unhandledRejection` or a warning +// is its exit code and stderr. +import { type Binden, BindenAbortError, BindenError } from "binden"; +import { + type ClientRequest, + type IncomingMessage, + type RequestOptions, + request, +} from "node:http"; +import { fstatSync, readdirSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; + +/** Fails the scenario once `ms` have passed: a hang must not block the runner */ +export function deadline(ms: number): void { + setTimeout(() => { + process.stderr.write(`Timed out after ${ms} ms\n`); + process.exit(2); + }, ms).unref(); +} + +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Polls `condition` until it holds, or fails after `ms` */ +export async function eventually( + condition: () => boolean, + message: string, + ms = 5000, +): Promise { + const end = Date.now() + ms; + while (!condition()) { + if (Date.now() > end) { + throw new Error(`Timed out: ${message}`); + } + await sleep(10); + } +} + +/** Serves an application on a free port */ +export async function serve( + app: Binden, +): Promise<{ server: ReturnType; port: number }> { + const server = app.createServer().listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { server, port }; +} + +/** A response, read to its end */ +export interface IReply { + status: number; + headers: IncomingMessage["headers"]; + body: Buffer; +} + +/** Reads a response to its end */ +export async function read(response: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of response) { + chunks.push(chunk as Buffer); + } + return { + status: response.statusCode ?? 0, + headers: response.headers, + body: Buffer.concat(chunks), + }; +} + +/** Starts a request (on a connection of its own, unless an `agent` is given) */ +export function start( + port: number, + path: string, + options: RequestOptions = {}, +): { client: ClientRequest; response: Promise } { + const client = request({ + agent: false, + ...options, + host: "127.0.0.1", + port, + path, + }); + const response = new Promise((resolve, reject) => { + // Every error: a client may go away on purpose once it has its response + client.once("response", resolve).on("error", reject); + }); + // Observed: the response of a client that goes away is not awaited + response.catch(() => null); + return { client, response }; +} + +/** A request and its response, read to its end */ +export async function exchange( + port: number, + path: string, + options: RequestOptions = {}, + body: Buffer | string = "", +): Promise { + const { client, response } = start(port, path, options); + client.end(body); + const reply = await read(await response); + return { ...reply, reused: client.reusedSocket }; +} + +/** The reason of an aborted signal, or `null` */ +export function reasonOf(signal: AbortSignal): string | null { + if (!signal.aborted) { + return null; + } + const { reason } = signal as { reason: unknown }; + return reason instanceof BindenAbortError ? reason.reason : "other"; +} + +/** + * The codes of a write to a connection that the client has closed (a write + * still in a compressor is called back with `ERR_STREAM_DESTROYED` once the + * response has closed) + */ +const disconnected = new Set([ + "ECONNRESET", + "EPIPE", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_PREMATURE_CLOSE", +]); + +/** + * What a promise has settled with: `resolved`, ` ` of a + * `BindenError`, `closed` for the connection closed by the client (the error + * depends on the version of Node.js), or the name and code of another error + */ +export async function outcome(promise: Promise): Promise { + try { + await promise; + return "resolved"; + } catch (error: unknown) { + if (error instanceof BindenError) { + return `${error.status} ${error.code ?? "-"}`; + } else if (error instanceof BindenAbortError) { + return `aborted ${error.reason}`; + } + if (error instanceof Error) { + const { code } = error as { code?: unknown }; + if (typeof code !== "string") { + return error.name; + } + return disconnected.has(code) ? "closed" : `${error.name} ${code}`; + } + return typeof error; + } +} + +/** + * The regular files that the process has open (POSIX), by device and inode: a + * file that is still open once its request is over (e.g. a removed temporary + * file, which a descriptor keeps) is a leak + */ +export function openFiles(): Set { + const dir = process.platform === "linux" ? "/proc/self/fd" : "/dev/fd"; + const files = new Set(); + for (const fd of readdirSync(dir)) { + try { + const stats = fstatSync(Number(fd)); + if (stats.isFile()) { + files.add(`${stats.dev}:${stats.ino}`); + } + } catch { + // Closed since (e.g. the descriptor of the listing itself) + } + } + return files; +} + +/** + * Waits until every regular file that the process has open was already open + * before (`openFiles()`): a file that another task closes meanwhile (e.g. a + * cache of tsx) does not count + */ +export function closed(before: Set, name: string): Promise { + return eventually( + () => [...openFiles()].every((file) => before.has(file)), + `${name}: the files that it has opened are closed`, + ); +} + +/** A multipart body of a form, and its `Content-Type` */ +export async function multipart( + 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") ?? "", + }; +} diff --git a/scripts/acceptance/keep-alive.ts b/scripts/acceptance/keep-alive.ts new file mode 100644 index 00000000..905ca32a --- /dev/null +++ b/scripts/acceptance/keep-alive.ts @@ -0,0 +1,32 @@ +// A compressed reply of `send()` (a known length, which the compression removes) +// keeps its connection alive: it is framed as chunked, not by closing the +// connection. +import { deadline, exchange, serve } from "./helpers.js"; +import { Agent } from "node:http"; +import { Binden } from "binden"; +import { Compression } from "@binden/compression"; + +deadline(10_000); + +const app = new Binden() + .use(new Compression()) + .use("/json", (context) => context.json({ text: "x".repeat(2000) })); +const { server, port } = await serve(app); +const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + +const replies = []; +for (let index = 0; index < 3; index += 1) { + const { headers, reused } = await exchange(port, "/json", { + agent, + headers: { "Accept-Encoding": "gzip" }, + }); + replies.push({ + reused, + encoding: headers["content-encoding"], + framing: headers["transfer-encoding"] ?? "close", + }); +} + +agent.destroy(); +server.close(); +process.stdout.write(`${JSON.stringify(replies)}\n`); diff --git a/scripts/acceptance/scenarios.test.mjs b/scripts/acceptance/scenarios.test.mjs new file mode 100644 index 00000000..c35dd812 --- /dev/null +++ b/scripts/acceptance/scenarios.test.mjs @@ -0,0 +1,138 @@ +// The process-level acceptance scenarios (`scripts/acceptance/*.ts`) against the +// workspaces (built): each one runs in a process of its own (with tsx), with a +// deadline, and must exit with code 0, an empty stderr (no crash, no +// `unhandledRejection`, no warning) and what it has observed on stdout. +import { describe, it } from "node:test"; +import { deepEqual } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const root = join(import.meta.dirname, "..", ".."); +// The scenarios use POSIX signals and descriptors +const skip = process.platform === "win32" && "POSIX only"; + +function run(scenario) { + const { status, signal, stdout, stderr } = spawnSync( + process.execPath, + ["--import", "tsx", join(import.meta.dirname, scenario)], + { cwd: root, encoding: "utf8", timeout: 60_000 }, + ); + let output = stdout; + try { + output = JSON.parse(stdout); + } catch { + // Compared as text + } + return { status, signal, stderr, output }; +} + +const item = { + name: "tea", + tags: Array.from({ length: 300 }, (_, index) => index), +}; + +describe("acceptance", { skip }, () => { + it("the body parser, the multipart parser and the compression as shared middlewares", () => { + deepEqual(run("shared.ts"), { + status: 0, + signal: null, + stderr: "", + output: { + json: { status: 200, body: { received: item }, reused: false }, + compressed: { + status: 200, + encoding: "gzip", + body: { received: item }, + }, + upload: { + status: 200, + body: { + fields: { title: ["Report"] }, + files: [{ field: "document", filename: "report.txt", size: 4096 }], + }, + reused: true, + }, + // Neither parser has read it + raw: { + status: 200, + body: { parsed: false, size: 100 * 1024 }, + reused: true, + }, + refused: [ + { status: 415, reused: true }, + { status: 415, reused: true }, + ], + leftovers: 0, + }, + }); + }); + + it("a client that goes away: the work settles, the signal is aborted, nothing is left", () => { + const aborted = { outcome: "400 aborted", signal: "client_closed" }; + // The reply rejects (the writes fail) + const closed = { outcome: "closed", signal: "client_closed" }; + deepEqual(run("disconnect.ts"), { + status: 0, + signal: null, + stderr: "", + output: { + body: aborted, + "verify never settles": aborted, + "multipart (memory)": aborted, + "multipart (disk)": aborted, + "multipart (stream, on_file never settles)": aborted, + uploads_left: 0, + // The signal is no longer aborted once the response has finished + "multipart after the response": { + status: 200, + outcome: "400 aborted", + signal: null, + stream: "destroyed", + }, + sendFile: { ...closed, encoding: null }, + compression: { ...closed, encoding: "gzip", source_closed: true }, + }, + }); + }); + + it("SIGTERM with an idle kept-alive connection, a stream of events and a slow upload", () => { + deepEqual(run("sigterm.ts"), { + status: 0, + signal: null, + stderr: "", + output: { + order: [ + "SIGTERM", + "idle connection closed", + "upload replied", + "events cut", + "shutdown resolved", + ], + refused: "ECONNREFUSED", + upload: { + status: 200, + connection: "close", + body: { size: 64 * 1024 }, + }, + events: { complete: false, after_sigterm: true }, + cleanup: "shutdown", + listening: false, + received: true, + }, + }); + }); + + it("a compressed reply of `send()` keeps its connection alive", () => { + const chunked = { encoding: "gzip", framing: "chunked" }; + deepEqual(run("keep-alive.ts"), { + status: 0, + signal: null, + stderr: "", + output: [ + { reused: false, ...chunked }, + { reused: true, ...chunked }, + { reused: true, ...chunked }, + ], + }); + }); +}); diff --git a/scripts/acceptance/shared.ts b/scripts/acceptance/shared.ts new file mode 100644 index 00000000..0ede3e07 --- /dev/null +++ b/scripts/acceptance/shared.ts @@ -0,0 +1,167 @@ +// The body parser, the multipart parser and the compression installed together as +// shared middlewares of the application: each parser handles its own media types and +// skips the others without reading them, so that JSON and forms reach their routes, +// another body is left to its route, and the routes require their own media type +// (415). Every request reuses one connection, after the refusals too. +import { Agent, type OutgoingHttpHeaders } from "node:http"; +import { Binden, BindenError, type Context, Router } from "binden"; +import { type IStoredForm, Multipart } from "@binden/multipart"; +import { deadline, eventually, exchange, multipart, serve } from "./helpers.js"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { BodyParser } from "@binden/body"; +import { Compression } from "@binden/compression"; +import { gunzipSync } from "node:zlib"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +deadline(20_000); + +const MiB = 1024 * 1024; +const uploads = mkdtempSync(join(tmpdir(), "binden-acceptance-")); +process.once("exit", () => { + rmSync(uploads, { recursive: true, force: true }); +}); + +/** Refuses a request whose body is not of `type` */ +function requireType(context: Context, type: string): void { + if (context.request.content_type?.type !== type) { + throw new BindenError(415); + } +} + +const app = new Binden({ timeout: 30_000 }) + .use( + new Compression(), + new BodyParser({ limit: MiB, encoded_limit: MiB }), + new Multipart({ + limit: 10 * MiB, + config: { limits: { fileSize: 5 * MiB, files: 10, fields: 100 } }, + storage: { dir: uploads }, + }), + ) + .use( + "/json", + new Router().post((context) => { + requireType(context, "application/json"); + return context.json({ received: context.request.body }); + }), + ) + .use( + "/upload", + new Router().post((context) => { + requireType(context, "multipart/form-data"); + const { fields, files } = context.request.body as IStoredForm; + return context.json({ + fields: Object.fromEntries(fields), + files: [...files.values()] + .flat() + .map(({ field, filename, size }) => ({ field, filename, size })), + }); + }), + ) + .use( + "/raw", + new Router().post(async (context) => { + // Neither parser has read the body: the route reads it + let size = 0; + for await (const chunk of context.request) { + size += (chunk as Buffer).length; + } + return context.json({ + parsed: typeof context.request.body !== "undefined", + size, + }); + }), + ); + +const { server, port } = await serve(app); +const agent = new Agent({ keepAlive: true, maxSockets: 1 }); +const post = ( + path: string, + headers: OutgoingHttpHeaders, + body: Buffer | string, +): ReturnType => + exchange(port, path, { agent, method: "POST", headers }, body); + +const results: Record = {}; + +const item = { name: "tea", tags: Array.from({ length: 300 }, (_, i) => i) }; +const json = await post( + "/json", + { "Content-Type": "application/json" }, + JSON.stringify(item), +); +results.json = { + status: json.status, + body: JSON.parse(json.body.toString()) as unknown, + reused: json.reused, +}; + +// Compressed by the shared compression, on a connection of its own +const compressed = await exchange( + port, + "/json", + { + method: "POST", + headers: { "Content-Type": "application/json", "Accept-Encoding": "gzip" }, + }, + JSON.stringify(item), +); +results.compressed = { + status: compressed.status, + encoding: compressed.headers["content-encoding"], + body: JSON.parse(gunzipSync(compressed.body).toString()) as unknown, +}; + +const form = new FormData(); +form.append("title", "Report"); +form.append("document", new File(["a".repeat(4096)], "report.txt")); +const upload = await multipart(form); +const files = await post( + "/upload", + { "Content-Type": upload.type }, + upload.body, +); +results.upload = { + status: files.status, + body: JSON.parse(files.body.toString()) as unknown, + reused: files.reused, +}; + +const raw = await post( + "/raw", + { "Content-Type": "application/octet-stream" }, + Buffer.alloc(100 * 1024), +); +results.raw = { + status: raw.status, + body: JSON.parse(raw.body.toString()) as unknown, + reused: raw.reused, +}; + +// Each route refuses the media type of the other, once its parser has read it +const wrongJson = await post( + "/json", + { "Content-Type": upload.type }, + upload.body, +); +const wrongForm = await post( + "/upload", + { "Content-Type": "application/json" }, + "{}", +); +results.refused = [wrongJson, wrongForm].map(({ status, reused }) => ({ + status, + reused, +})); + +// The files that the routes have not kept are removed with their request +await eventually( + () => readdirSync(uploads).length === 0, + "the private directories are removed", +); +results.leftovers = readdirSync(uploads).length; + +agent.destroy(); +server.close(); +process.stdout.write(`${JSON.stringify(results)}\n`); diff --git a/scripts/acceptance/sigterm.ts b/scripts/acceptance/sigterm.ts new file mode 100644 index 00000000..857b5c58 --- /dev/null +++ b/scripts/acceptance/sigterm.ts @@ -0,0 +1,163 @@ +// SIGTERM, with `shutdown()`, while a kept-alive connection is idle, a stream of +// server-sent events is open and a slow upload is in progress: the idle connection +// is closed at once, no connection is accepted any more, the upload completes (its +// reply has `Connection: close`), the event stream goes on until the drain time +// has passed and is then cut (its signal is aborted: `shutdown`), and the process +// exits by itself once `shutdown()` has resolved. +import { Binden, type Context, Router, shutdown } from "binden"; +import { + deadline, + exchange, + read, + reasonOf, + serve, + sleep, + start, +} from "./helpers.js"; +import { Agent } from "node:http"; +import { BodyParser } from "@binden/body"; +import { setTimeout as wait } from "node:timers/promises"; + +deadline(20_000); + +/** The drain time of the shutdown */ +const drain = 1500; +const KiB = 1024; + +/** What has happened, in order */ +const order: string[] = []; +const results: Record = { order }; + +const uploading = Promise.withResolvers(); +const streaming = Promise.withResolvers(); +const cleaned = Promise.withResolvers(); + +const events = async (context: Context): Promise => { + context.setTimeout(0); + context.done = true; + context.onCleanup(() => { + cleaned.resolve(reasonOf(context.signal)); + }); + const { response, signal } = context; + response.setHeader("Content-Type", "text/event-stream"); + try { + // Unaware of the shutdown: it is cut once the drain time has passed + for (let id = 1; ; id += 1) { + response.write(`id: ${id}\n\n`); + await response.flush(); + streaming.resolve(null); + await wait(50, null, { signal }); + } + } catch { + // Aborted + } +}; + +const app = new Binden({ timeout: 30_000 }) + .use("/ok", (context: Context) => context.text("ok")) + .use("/events", events) + .use( + "/upload", + new Router().post( + () => { + uploading.resolve(null); + }, + new BodyParser({ limit: 1024 * KiB }), + (context: Context) => + context.json({ size: (context.request.body as string).length }), + ), + ); + +const { server, port } = await serve(app); +// Wrapped: a promise resolved with the promise of `shutdown()` would wait for it +const terminated = new Promise<{ closing: Promise }>((resolve) => { + process.once("SIGTERM", () => { + order.push("SIGTERM"); + resolve({ closing: shutdown(server, { timeout: drain }) }); + }); +}); + +// A kept-alive connection, idle +const agent = new Agent({ keepAlive: true }); +const ok = start(port, "/ok", { agent }); +ok.client.end(); +const idle = await ok.response; +const { socket } = idle; +await read(idle); +socket.once("close", () => { + order.push("idle connection closed"); +}); + +// A stream of events +const stream = start(port, "/events"); +stream.client.end(); +const sse = await stream.response; +let received = 0; +let afterSigterm = 0; +let signalled = false; +sse.on("data", () => { + received += 1; + if (signalled) { + afterSigterm += 1; + } +}); +const cut = new Promise((resolve) => { + sse.once("close", () => { + order.push("events cut"); + results.events = { + complete: sse.complete, + after_sigterm: afterSigterm > 0, + }; + resolve(); + }); +}); +sse.on("error", () => { + // The stream is cut on purpose +}); +await streaming.promise; + +// A slow upload: a quarter of its body, then SIGTERM +const size = 64 * KiB; +// On a kept-alive connection of its own: `Connection: close` comes from the shutdown +const uploads = new Agent({ keepAlive: true }); +const upload = start(port, "/upload", { + agent: uploads, + method: "POST", + headers: { "Content-Type": "text/plain", "Content-Length": size }, +}); +upload.client.write("a".repeat(size / 4)); +await uploading.promise; + +process.kill(process.pid, "SIGTERM"); +const { closing } = await terminated; +signalled = true; + +// No connection is accepted any more +results.refused = await exchange(port, "/ok").then( + () => "accepted", + (error: unknown) => (error as { code?: string }).code, +); + +// The rest of the upload, slowly +for (let part = 1; part < 4; part += 1) { + await sleep(100); + upload.client.write("a".repeat(size / 4)); +} +upload.client.end(); +const uploaded = await read(await upload.response); +order.push("upload replied"); +results.upload = { + status: uploaded.status, + connection: uploaded.headers.connection, + body: JSON.parse(uploaded.body.toString()) as unknown, +}; + +await cut; +results.cleanup = await cleaned.promise; +await closing; +order.push("shutdown resolved"); +results.listening = server.listening; +results.received = received > 1; +agent.destroy(); +uploads.destroy(); +process.stdout.write(`${JSON.stringify(results)}\n`); diff --git a/scripts/acceptance/tsconfig.json b/scripts/acceptance/tsconfig.json new file mode 100644 index 00000000..5c403d7f --- /dev/null +++ b/scripts/acceptance/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 00000000..cdd719d0 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +// 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"; + +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/consumer-check.mjs b/scripts/consumer-check.mjs new file mode 100644 index 00000000..a06a3528 --- /dev/null +++ b/scripts/consumer-check.mjs @@ -0,0 +1,483 @@ +#!/usr/bin/env node +// Checks the packed workspaces the way a consumer uses them: +// +// - every bare import of the modules and declarations of an archive (`dist/`) is +// declared by its `package.json` (`scripts/imports.mjs`); +// - the tarballs are installed into a fresh project, which is type checked and then +// run as ESM against a real server. The TypeScript examples of the READMEs and of +// the guides at the root (`documents`) are type checked against the same +// installation, and so are the runnable examples (`examples/*.ts`), which are then +// compiled, run and checked over HTTP (`scripts/examples.mjs`, POSIX only); +// - each tarball is installed alone into a project of its own, with what it declares +// (its dependencies and peers, from the other tarballs), then type checked and +// imported (its root and its `package.json`, while its deep paths are closed). +// +// The installation of all the tarballs is checked with: +// +// - 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. +// +// 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 { basename, join, resolve } from "node:path"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { examples, runExample } from "./examples.mjs"; +import { readRelease, readTarball } from "./tarballs.mjs"; +import { execFileSync } from "node:child_process"; +import { npm } from "./npm.mjs"; +import { tmpdir } from "node:os"; +import { undeclaredImports } from "./imports.mjs"; + +const root = join(import.meta.dirname, ".."); +const readJSON = (path) => JSON.parse(readFileSync(join(root, path), "utf8")); +const { devDependencies, workspaces } = readJSON("package.json"); +// The guides at the root of the repository, whose examples are checked as those of +// the READMEs (each one must exist and have some) +const documents = ["MIGRATION-4.md", "PRODUCTION.md"]; +// 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"; +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, "127.0.0.1"); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const url = \`http://127.0.0.1:\${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(); +} +`; + +// 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-")); + +const blockPattern = /^```typescript\r?\n(?[\s\S]*?)^```\r?$/gmu; + +/** The `typescript` blocks of a Markdown file */ +const blocksOf = (markdown) => + [...markdown.matchAll(blockPattern)].map(({ groups }) => groups.code); + +/** + * Writes every `typescript` block of the READMEs and of the guides at the root + * (`documents`) as a module of `snippets/` + */ +function writeSnippets() { + const target = join(dir, "snippets"); + mkdirSync(target); + let count = 0; + const write = (name, blocks) => { + for (const [index, code] of blocks.entries()) { + writeFileSync(join(target, `${name}-${index + 1}.ts`), code); + count += 1; + } + }; + for (const workspace of workspaces) { + const readme = readFileSync(join(root, workspace, "README.md"), "utf8"); + write(workspace.split("/").at(-1), blocksOf(readme)); + } + if (count === 0) { + throw new Error("No TypeScript examples found in the READMEs"); + } + for (const document of documents) { + const path = join(root, document); + if (!existsSync(path)) { + throw new Error( + `${document} is missing: its examples are checked (\`documents\` of scripts/consumer-check.mjs)`, + ); + } + const blocks = blocksOf(readFileSync(path, "utf8")); + if (blocks.length === 0) { + throw new Error(`No TypeScript examples found in ${document}`); + } + write(basename(document, ".md"), blocks); + } + writeFileSync(join(target, "globals.d.ts"), globals); + process.stderr.write( + `[consumer] ${count} examples of the READMEs and of ${documents.join(", ")}\n`, + ); + return target; +} + +/** Copies the runnable examples (`examples/*.ts`) into `runnable/` */ +function copyRunnable() { + const target = join(dir, "runnable"); + mkdirSync(target); + const source = join(root, "examples"); + const files = readdirSync(source).filter((file) => file.endsWith(".ts")); + for (const { file } of examples) { + if (!files.includes(`${file}.ts`)) { + throw new Error(`examples/${file}.ts is missing (scripts/examples.mjs)`); + } + } + for (const file of files) { + copyFileSync(join(source, file), join(target, file)); + } + process.stderr.write(`[consumer] ${files.length} runnable examples\n`); + return target; +} + +/** Checks that every archive declares the packages that its `dist/` imports */ +function checkImports(tarballs) { + const errors = tarballs.flatMap((path) => { + const files = readTarball(readFileSync(path)); + const manifest = JSON.parse(files.get("package.json")); + return undeclaredImports(manifest, files); + }); + if (errors.length > 0) { + throw new Error(`Undeclared imports:\n ${errors.join("\n ")}`); + } + process.stderr.write( + `[consumer] the imports of ${tarballs.length} archives are declared\n`, + ); +} + +function run(command, args, cwd = dir) { + process.stderr.write(`[consumer] ${command} ${args.join(" ")}\n`); + const options = { cwd, stdio: ["ignore", "inherit", "inherit"] }; + if (command === "npm") { + npm(args, options); + } else { + execFileSync(command, args, options); + } +} + +/** 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, + ); + return readdirSync(dir) + .filter((file) => file.endsWith(".tgz")) + .map((file) => join(dir, file)); +} + +/** + * Installs each tarball alone (with `--install-strategy=nested`) into a project of + * its own, outside of `dir` (whose `node_modules` would hide a missing + * dependency): npm installs its dependencies and peers, which `overrides` take + * from the other tarballs. The project is type checked (with the TypeScript and + * `@types/node` of `dir`), then the package is imported: its root, and its + * `package.json` (as JSON), while a module of `dist/` is not exported. + */ +function checkStandalone(tarballs) { + const packages = tarballs.map((path) => { + const files = readTarball(readFileSync(path)); + const modules = [...files.keys()] + .filter((file) => /^dist\/.+\.js$/u.test(file)) + .sort(); + // A module of the package that its `exports` do not export (deep imports are closed) + const deep = + modules.find((file) => file.startsWith("dist/src/")) ?? modules[0]; + return { name: JSON.parse(files.get("package.json")).name, path, deep }; + }); + for (const { name, path, deep } of packages) { + const project = mkdtempSync(join(tmpdir(), "binden-standalone-")); + try { + process.stderr.write(`[consumer] ${name} alone\n`); + const overrides = Object.fromEntries( + packages + .filter((other) => other.name !== name) + .map((other) => [other.name, `file:${other.path}`]), + ); + writeFileSync( + join(project, "package.json"), + `${JSON.stringify({ private: true, type: "module", overrides })}\n`, + ); + run( + "npm", + [ + "install", + "--install-strategy=nested", + "--no-audit", + "--no-fund", + "--loglevel=warn", + path, + ], + project, + ); + writeFileSync( + join(project, "index.ts"), + `import * as module from ${JSON.stringify(name)};\n\nexport const names: string[] = Object.keys(module);\n`, + ); + writeFileSync( + join(project, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { + module: "nodenext", + target: "es2022", + strict: true, + noEmit: true, + types: ["node"], + typeRoots: [join(dir, "node_modules", "@types")], + }, + files: ["index.ts"], + })}\n`, + ); + run( + process.execPath, + [join(dir, "node_modules/typescript/bin/tsc"), "-p", project], + project, + ); + // Its root has exports, its `package.json` is exported, its modules are not + run( + process.execPath, + [ + "--input-type=module", + "--eval", + `const name = ${JSON.stringify(name)}; +const module = await import(name); +if (Object.keys(module).length === 0) { + throw new Error(\`\${name} has no exports\`); +} +const { default: manifest } = await import(\`\${name}/package.json\`, { + with: { type: "json" }, +}); +if (manifest.name !== name) { + throw new Error(\`The package.json of \${name} names \${manifest.name}\`); +} +const deep = ${JSON.stringify(`${name}/${deep}`)}; +await import(deep).then( + () => { + throw new Error(\`\${deep} is exported\`); + }, + (error) => { + if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") { + throw error; + } + }, +);`, + ], + project, + ); + } finally { + rmSync(project, { force: true, recursive: true }); + } + } +} + +try { + const tarballs = pack(); + checkImports(tarballs); + + writeFileSync( + join(dir, "package.json"), + `${JSON.stringify({ private: true, type: "module" })}\n`, + ); + writeFileSync(join(dir, "consumer.ts"), consumer); + const snippets = writeSnippets(); + const runnable = copyRunnable(); + + for (const [round, { typescript, types, skipLibCheck }] of [ + { + typescript: devDependencies.typescript, + types: "24", + skipLibCheck: false, + }, + { typescript: devDependencies.typescript, types: "26", skipLibCheck: true }, + { typescript: "7", types: "24", skipLibCheck: false }, + ].entries()) { + 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 + // is not declared (but hoisted in this repository) fails + "--install-strategy=nested", + "--no-audit", + "--no-fund", + "--loglevel=warn", + ...tarballs, + `typescript@${typescript}`, + `@types/node@${types}`, + ...Object.entries(exampleDependencies).map( + ([name, version]) => `${name}@${version}`, + ), + ]); + const tsc = join(dir, "node_modules/typescript/bin/tsc"); + 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, [tsc, "-p", dir]); + run(process.execPath, [join(dir, "dist/consumer.js")]); + + // Each example is a module (`moduleDetection`), checked as strictly as the consumer + writeFileSync( + join(snippets, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { + module: "nodenext", + target: "es2022", + strict: true, + moduleDetection: "force", + noEmit: true, + skipLibCheck, + types: ["node"], + }, + include: ["*.ts"], + })}\n`, + ); + run(process.execPath, [tsc, "-p", snippets]); + + // The runnable examples, as strictly as in this repository (`exactOptionalPropertyTypes`), + // compiled + writeFileSync( + join(runnable, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { + module: "nodenext", + target: "es2022", + strict: true, + exactOptionalPropertyTypes: true, + skipLibCheck, + types: ["node"], + outDir: "dist", + }, + include: ["*.ts"], + })}\n`, + ); + run(process.execPath, [tsc, "-p", runnable]); + + if (round === 0) { + if (process.platform === "win32") { + process.stderr.write("[consumer] the examples are run on POSIX only\n"); + } else { + for (const example of examples) { + process.stderr.write(`[consumer] example ${example.name}\n`); + await runExample(example, { + command: process.execPath, + args: [join(runnable, "dist", `${example.file}.js`)], + cwd: dir, + }); + } + } + checkStandalone(tarballs); + } + } + process.stderr.write("[consumer] OK\n"); +} finally { + rmSync(dir, { force: true, recursive: true }); +} diff --git a/scripts/consumer-check.test.mjs b/scripts/consumer-check.test.mjs new file mode 100644 index 00000000..daba7eca --- /dev/null +++ b/scripts/consumer-check.test.mjs @@ -0,0 +1,125 @@ +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 { npmInstallation } from "./fixtures/npm-installation.mjs"; +import { npmTarball } from "./fixtures/tar.mjs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +// A fake npm CLI (run as the CLI of an npm installation, `npm_execpath`: see +// fixtures/npm-installation.mjs) 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 = ""; + // The CLI of the npm installation of the fake + let cli = ""; + + const check = (...args) => + spawnSync( + process.execPath, + [join(import.meta.dirname, "consumer-check.mjs"), ...args], + { + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + npm_execpath: cli, + 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); + cli = npmInstallation(dir, join(dir, "npm.cjs")); + }); + + 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/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/examples.mjs b/scripts/examples.mjs new file mode 100644 index 00000000..e4cdd1c6 --- /dev/null +++ b/scripts/examples.mjs @@ -0,0 +1,637 @@ +// 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 of 127.0.0.1 (`PORT`: 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 { createServer, request } from "node:http"; +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 { once } from "node:events"; +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(`