Skip to content

build!: binden monorepo with npm workspaces and lockstep releases - #246

Open
vansergen wants to merge 500 commits into
mainfrom
build/monorepo
Open

vansergen wants to merge 500 commits into
mainfrom
build/monorepo

Conversation

@vansergen

@vansergen vansergen commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Checklist:

  • I have run npm run ci:all locally and all tests are passing (Node.js 24.16 and 26.11).
  • If this is a significant change, an issue has already been created where the problem / solution was discussed: N/A

Description

Moves binden, @binden/logger, @binden/body, @binden/compression and @binden/multipart into this repository as npm workspaces (packages/*). All packages are released together with one version by semantic-release. Besides the infrastructure and CI, it updates the dependencies, fixes the library bugs found while reviewing this PR (see Library fixes and Breaking changes), and relicenses every package under MIT.

What changes

  • History. binden is moved with git mv, so its history and tags are kept. The sources of logger, body, compression and multipart are copied without their history: it stays in their repositories, which will be archived. The import commit lists the upstream SHAs. The unreleased changes of @binden/logger (pino 10, MIT license) are separate commits, so they show up in the release notes.

  • Lockstep releases. scripts/release.mjs is called from @semantic-release/exec:

    • verify: fails if any package is already published at the next version;
    • version: sets the version in every workspace, rewrites internal dependency ranges to ^<version> and refreshes the lockfile (in the checkout of the release job only);
    • publish: publishes the packages in dependency order (logger → binden → body → compression → multipart).

    npm run release:check (run in CI) checks that all workspaces stay in lockstep. Only a confirmed E404 from the registry counts as "not published" in verify (network or auth errors stop the release before anything is tagged), and publish runs the lockstep check first.

  • Recovery. semantic-release pushes the tag before the packages are published. A release that fails after its tag is recovered by running the CI workflow by hand (workflow_dispatch with the version): the recover job checks out the tag, publishes the packages that are not published yet (with provenance) and creates the missing GitHub Release. publish --skip-existing refuses to run from another commit than the tag, and refuses a package already published from another commit (its gitHead). Tested with a fake npm (failure after each workspace, recovery, wrong commit).

  • No release commit. A release creates the v<version> tag, publishes the packages and creates a GitHub Release whose notes are the changelog. Nothing is committed back to main: a release commit pushed with GITHUB_TOKEN would be rejected by the required status checks, and semantic-release recommends against it. The versions in the repository therefore stay at 0.0.0-development (internal ranges ^0.0.0-development). The root CHANGELOG.md points to the GitHub Releases and to the changelogs of the former packages. @semantic-release/git, @semantic-release/changelog and the GPG setup of the release job are removed.

  • Configs live in the repo. They are written in place instead of coming from the @binden/*-config packages:

    • TypeScript: tsconfig.base.json plus a one-line tsconfig.json per package; build type-checks the tests as well.
    • ESLint, commitlint, semantic-release, c8, TypeDoc.
    • ESLint rules are the ones from @binden/eslint-config@7.0.1 and @binden/eslint-config-ts@6.0.2. Entries that change nothing were dropped; effective rules were verified to be identical. Rule violations in code carried over from the old repositories are switched off per file, with a comment.
  • Tests.

    • mocha and ts-node are replaced by node:test with tsx. Every workspace runs scripts/test.mjs, which passes extra options to Node.js before the test files (npm test -w packages/body -- --test-name-pattern=JSON, npm test -- --test-reporter=dot).
    • @binden/logger had no tests (and was missing from the coverage report, since binden loads it from dist): node:test tests are added.
    • c8 includes every package source (all) and enforces 100% lines/branches/functions/statements, so an untested package or line fails CI.
    • Process safety tests run fixtures in a process of their own (a crash fails the test, not the runner), and a keep-alive suite sends every request of a test on one connection (a framing error shows as a hang or a wrong body).
    • Every new test was checked by reverting its fix (mutation), on Node 24 and 26 where they differ.
    • Test servers listen on ephemeral ports (listen(0)), and the files of the BindenResponse tests live in a new temporary directory per test, so a local service on a fixed port or a second run cannot break the tests. packages/*/test/setup.ts disables HTTP keep-alive for the test clients (node:http agent, undici global dispatcher, also used by fetch): some tests depend on the client closing its connection after the response, and a pooled socket would outlive its test.
    • @binden/multipart only gets type annotations required by the current @types/node and @types/busboy (Buffer<ArrayBuffer>, filename as string).
  • Tooling works on Windows: prebuild removes dist with scripts/clean.mjs (no rm -rf), npm is run through scripts/npm.mjs (Node.js with npm_execpath, since execFile("npm") cannot run npm.cmd), the tooling tests use no shebangs, chmod, :-joined PATH or symbolic links (a junction), and .gitattributes checks out text files with LF.

  • CI: one workflow, ci.yml:

    • Actions pinned by commit (Dependabot updates them); the pre-commit hook runs the installed lint-staged only (npx --no).
    • lint on Node 24: lockstep check, Prettier, commitlint for the pushed range, ESLint, tooling tests (scripts/*.test.mjs: release script with a fake npm, npm launcher, test runner, pack check), the packed files of every package against scripts/pack-files.json, consumer check, a release rehearsal (semantic-release in dry-run mode with the real configuration in a clone with a local mirror, the next version and its notes, then release.mjs verify and version), TypeDoc (treatWarningsAsErrors).
    • Consumer check (scripts/consumer-check.mjs): the packed tarballs are installed into a fresh project with --install-strategy=nested (so an undeclared dependency fails, as it did for pino in binden), type checked and run as ESM against a real server. Every typescript block of the package READMEs (62 examples) is type checked (not run) against the same installation, with the same strict options. Three combinations: TypeScript 6 with @types/node 24 (skipLibCheck: false), TypeScript 6 with @types/node 26 (skipLibCheck: true because of an upstream thread-stream issue, see Known issues in the README), and TypeScript 7 with @types/node 24.
    • test on Node 24.14.1 (the oldest supported version), 24 and 26 with coverage; Coveralls upload from Node 24.
    • windows (new): npm ci, the tests and the tooling tests on windows-latest with Node 24.
    • release on main, after lint, test and windows: semantic-release (tag, npm, GitHub Release), without a release commit (see above).
    • recover, by hand only (see Recovery).
    • pages on main: deploys the TypeDoc site to GitHub Pages after the checks, without waiting for the release (a failed release no longer skips the docs).
  • Engines. The packages (and the tooling) require Node.js >=24.14.1 and npm >=11.11.0; the packages are compiled for ES2024.

  • .env files are ignored again; the install:clean script (which deleted the lockfile) is removed.

Dependencies

Updated to their latest versions: pino 10.4.0 (@binden/logger), @types/busboy 1.5.5 (peer of @binden/multipart), and for the tests fast-json-stringify 7.1.0, sinon 22.1.0 (with @types/sinon 22) and undici 8.11.2 (@binden/body); the lockfile is refreshed within the declared ranges. binden no longer imports pino at all (its serializers are built from an allowlist).

Deliberately not updated (explained in the README, Dependencies):

  • TypeScript 7: typescript-eslint (<6.1.0) and TypeDoc (≤6.0.x) need the JavaScript API that the native compiler does not have yet. The repository builds with TypeScript 6; the consumer check type checks the packages and the README examples with TypeScript 7.
  • @types/node 26: the types follow the oldest supported Node.js (24); the tests run on 24 and 26.
  • conventional-changelog-conventionalcommits 10: semantic-release 25 uses conventional-changelog-writer 8, and preset 10 requires writer 9 (a release dry-run fails with it).

npm audit --omit=dev reports no vulnerabilities. The 13 advisories of the dev dependencies are in the release tooling and have no fix yet: braces 3.0.3 (through micromatch of semantic-release) is the latest version, and the others are bundled with npm 11.21.0, the latest npm that @semantic-release/npm supports.

Library fixes

Inherited from the former repositories (or found by the reviews of this PR), each with a regression test checked by reverting the fix:

Responses (binden)

  • send() settles on what reaches the connection: a body (string, buffer or Readable) is written with a callback, so that a failed write rejects (Node.js 24 emits finish after a failed write once the response is ended); a Readable counts a chunk once it is flushed. It rejects when a Readable fails, closes before its end, or does not have its Content-Length: nothing sent yet, the error can still be replied; otherwise the response is cut, so that the client knows that it is truncated.
  • Every response is framed by its final status: a body gets its exact Content-Length (a stale one is replaced), 1xx/204/304 have none, 205 has Content-Length: 0, HEAD gets the length of the body it would send. Before, a stale Content-Length left clients waiting or desynchronized keep-alive connections (204, 205, the 416 of sendFile(), error replies).
  • A Readable that is not sent (to HEAD, 1xx/204/205/304, or an ended response) is destroyed with its errors ignored: a failing destroy() crashed the process, and an ended response kept the file open.
  • set() sets the own properties only (an inherited, or polluted, property was sent as a header).
  • sendFile() reads the metadata and the content from one file handle (no more than its size is sent; a FIFO is not waited for), and evaluates the preconditions in RFC 9110 order: If-Match/If-Unmodified-Since → 412, If-None-Match (weak comparison) → 304 or 412, If-Modified-Since → 304, then Range (GET only, satisfiable ranges merged, 416 otherwise) with If-Range (a strong ETag, or the Last-Modified when it is a strong validator).

Requests and routing (binden)

  • request.URL is assembled from the protocol of the connection, the authority (of an absolute-form target, otherwise Host) and the path of the request-target: //evil.test/x is a path, https://… targets no longer make secure true, and dot-segments, fragments, backslashes, invalid percent-encoding and userinfo get 400. request.secure is protocol === "https:".
  • trust_proxy (default false) decides whether Forwarded is trusted at all; the trusted element is taken by its position among the raw elements (an invalid element of a proxy no longer lets the one of the client take its place), with fail-closed parsing.
  • request.query keeps its prototype (__proto__ keys), cookies are validated (no attribute injection) and an empty cookie value is kept, and the header parsers follow RFC 9110: Authorization, Content-Type (every parameter kept and quoted), Content-Encoding (unknown codings → 415 in @binden/body), Range, Content-Range, Accept-Encoding (qvalues), If-Modified-Since (HTTP-date only); their constructors reject what the parsers reject.
  • Routing: an explicit HEAD handler is used, RegExp paths keep their flags (minus g/y), a guarded router that refuses the method passes the request on (405 with the union of Allow when nothing handles it), CONNECT routes are rejected (Node.js never routes them to the middlewares).

Errors and logging (binden)

  • A request gets a reply in these cases, which used to leave it pending: done without a reply (an empty response), the context of another request returned by a middleware (500), any thrown value (null, a symbol status, a throwing getter → 500), an async error_handler (awaited; nothing started → the default reply; a reply that is not ended once it settles is cut by closing the connection). A handler that never settles keeps its request: there is no deadline.
  • The default error reply goes through send(): with the queued cookies, the framing above, and without the representation headers of the failed body. Status codes are any integer from 100 to 599.
  • Logging cannot crash the process: a logger whose child() throws is used itself, logger calls are guarded, and the serializers never throw (a cyclic chain of prototypes is bounded). The default logs are an allowlist: a request is its method, its path (without query, fragment and userinfo; the path itself is logged), and a few headers; errors are their built-in type, status, expose and a Node.js error code.

Middlewares

  • @binden/compression: completes like an uncompressed response (end() callback, writableEnded, backpressure, errors), never compresses partial responses or responses without a body, negotiates by effective quality, and weakens a strong ETag of what it compresses.
  • @binden/body: a new limit option (decoded bytes, 413, reading stops; none by default, as decided, the README recommends one), decoding with the charset, 415 for an unknown coding or charset, and undefined from a custom parse (as Ajv parsers return) is a failure (415). A client disconnecting during the upload no longer leaves it pending.
  • @binden/multipart: a new limit option for the whole request (413, reading stops; none by default, the README recommends limits for the files, which are held in memory), a cut-off file part no longer crashes the process, throw_limits rejects truncated fields, and an aborted upload settles.

READMEs: the examples compile (type checked with TypeScript 6 and 7), the Router, the request-target and the contracts above are documented, and SECURITY.md asks for private reports.

Breaking changes (besides the move)

  • trust_proxy: request.protocol/secure/URL ignore Forwarded unless trust_proxy trusts the proxies (an application behind a TLS-terminating proxy needs new Binden({ trust_proxy: 1 })).
  • Request-target: targets with dot-segments, fragments, backslashes, invalid percent-encoding or userinfo get 400; secure and the protocol of URL no longer follow the scheme of an absolute-form target.
  • Cookies: new Cookie() throws a TypeError for an invalid name, value or attribute; Cookie.fromString() skips invalid cookies.
  • Headers: ContentEncoding.fromString()/request.content_encoding return null for an unknown coding; the constructors of Range, AcceptEncoding and ContentType throw a TypeError for invalid input, and ContentType#toString() includes every parameter.
  • Status codes: response.status() and new BindenError() throw a TypeError for a non-integer status (such as "400"); any integer from 100 to 599 is accepted.
  • Routing: router.on("CONNECT")/off("CONNECT") throw a TypeError.
  • sendFile() takes the path only (the Stats argument is removed).
  • @binden/body: a custom parse that returns undefined makes it reply 415.
  • Logs: the default serializers log an allowlist (see above).
  • Licenses: @binden/body, @binden/compression and @binden/multipart are now MIT (like binden and @binden/logger).

First release: 4.0.0

@binden/compression@3.0.0 is already on npm, so the first common version must be higher. The annotated tag v3.0.0 (already pushed) is a bootstrap marker on the binden 2.0.4 release commit (the same commit as v2.0.4). Counting from that tag, a semantic-release dry-run (now also run by CI, see release rehearsal) gives 4.0.0, with the breaking changes above in its notes. The release notes contain only the unreleased changes (ct_text → text/plain, MIT license for binden and logger, pino 10), the custom logger feature, the library fixes above and the migration.

The tag was moved once (from an earlier baseline commit) and will stay where it is now. A clone that fetched the old tag reports would clobber existing tag on git fetch --tags; fix it with git fetch --tags --force (or git tag -d v3.0.0 && git fetch --tags).

Old per-package changelogs stay in packages/*/CHANGELOG.md; the notes of the new releases are the GitHub Releases.

Before merging

  • Settings → Pages → Source: GitHub Actions (done: Pages no longer serves main:/docs, which this PR removes).
  • npm: set up Trusted Publishing (repository binden-js/binden, workflow ci.yml) for all 5 packages, or provide an NPM_TOKEN that can publish all of them.
  • Disable releases in the old repositories logger, body, compression and multipart, so they cannot publish out-of-band versions.
  • Branch protection of main: required status checks lint, test (24.14.1), test (24), test (26) and windows (none are required now), a required review, and enforce_admins. This is compatible with the release now that it does not commit to main.
  • Optional hardening: a release environment with required reviewers for the release/recover jobs; private vulnerability reporting (see SECURITY.md), secret scanning and push protection.
  • After merging: delete the secrets that the release job no longer uses: GPG_KEY, GPG_KEY_ID, GPG_KEY_PASSPHRASE, GIT_COMMITTER_EMAIL, GIT_COMMITTER_NAME.
  • Merge with a merge commit only, without squash or rebase, so the separate commits (and their release notes) are kept.

🤖 Generated with Claude Code

vansergen and others added 15 commits October 15, 2026 09:02
…binden/compression

The compression replaces the write methods of the response itself, so
the guard of binden did not see the calls of the abandoned work (e.g. a
middleware still writing after the deadline): they decided the coding,
created the compressor and committed the headers under the reply of the
error handler. Its entry points now ignore the work that may no longer
mutate the response, as binden does, and call the callbacks once,
asynchronously.

The compressor keeps the operation of the work that has committed the
headers, and writes its output as that owner. Once the response has been
ended through it, the transfer of what it has accepted goes on even when
that owner is abandoned afterwards: an error handler that times out with
an ended, compressed reply under backpressure no longer leaves the client
waiting for the rest of the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stream checked its Content-Length whenever it read it, so once a
compression had removed the header at its first write, a stream shorter
or longer than its declared length ended with a complete chunked body.
The length is now read once, before the first write, and a mismatch
cuts the response and rejects, compressed or not.

A string sent with an encoding was measured by Buffer.byteLength(),
which only estimates the bytes of base64 (e.g. with line breaks) or of
invalid hex: the declared length did not match the body, and the next
response of the connection was read as a part of it. It is now encoded
once, and those bytes give the length, the ETag and the body.

HEAD of an empty body had no Content-Length (and a compression could
then decide otherwise than for GET), HEAD got a default Content-Type
also for 204 and 304, and send("") took another path than an empty
Buffer (no ETag). Both methods now get the same headers, with the
default Content-Type only for a status that has a body. A stream that
has already ended is an empty body (of its declared length), and one
that has been destroyed rejects at once instead of never settling. A
1xx status, which the client takes for an interim response, rejects
with a TypeError (use writeEarlyHints()).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With root, a socket inside the root could not be opened (ENXIO, or
EOPNOTSUPP on macOS), which was not taken for a missing file: the
request got a 500 instead of a 404. Every other kind of file was opened
before its type was checked, which may have effects of its own for a
device. The real path is now checked to be a regular file (without
following a link) before it is opened: anything else is a 404, and the
errors of a file replaced by a socket or a device between the check and
the open (ENXIO, EOPNOTSUPP, ENODEV) are 404 as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…) in binden

The parser of Content-Type drops the parameters that it cannot read, so
type() accepted e.g. "text/plain; charset" or "text/plain; a=b c" and
set another value than the one it was given. It now throws a TypeError
unless every parameter is a token with a token or a quoted string as
value (RFC 9110, section 8.3.1), with optional whitespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once a response is sealed (e.g. its deadline has passed), the writes of the abandoned work are
ignored, but the access log still counted their bytes in `body_bytes`. It counts a write only when
the work that makes it owns the response. The error reply no longer lists `Content-Type` twice in
the headers it removes: the representation headers include it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the pack rules

The review of the pack rules slipped files through them. They only read a `//#` link on
the last line of a generated file, while Node.js and the browsers follow the last link
wherever it is and in the `/*# … */` form too: a local path could be published behind a
trailing comment. SSH keys (`id_*`), keystores (`.jks`, `.keystore`), PKCS#8 and PuTTY
keys (`.p8`, `.ppk`), the credential files of netrc, PyPI and git, Go-style tests
(`*_test.*`) and `__mocks__/` were not refused at all, and `Test/` or `.NPMRC` escaped
the rules although they are the same names on the file systems of macOS and Windows.

Every link of a generated file is now found in both comment forms and the last one is
checked; the names are compared without case; the new keys, credential files, tests and
mock directories are refused, whatever scripts/pack-files.json lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… checked

The release job packed the archives itself, with the rights to publish, and the archives that the
lint job checked (the rehearsal, the packed files and the consumer check) were other ones, packed
again from the same commit: nothing proved that npm received the bytes that were checked.

A `package` job (contents: read, no npm token, no OIDC) now computes the next version as the
rehearsal does, runs `version` and `pack` on Node.js 24.16.0 and npm 11.13.0, checks the archives
(pack-check and consumer-check with --tarballs) and uploads them with their manifest as an
artifact of the run, with the digest of the manifest as a job output. The release job installs
only its tooling (npm ci --ignore-scripts), downloads that artifact by the id that the job
reported (never an artifact of another run), compares the digest of its manifest, and lets
semantic-release publish it: `prepareCmd` runs `release.mjs version` (manifests and lockfile, no
build, no pack) and `publish --dry-run`, so the existing publisher checks the version, HEAD as the
commit, the integrity, the files and the pack rules of every archive before the tag is pushed;
`publishCmd` publishes these archives after its registry preflight. A recovery takes the same
way, where `recover-package` checks the version, the tag and its ancestry before anything of the
repository runs and packs the tagged commit, and `recover` checks them again, then publishes that
artifact with --skip-existing.

The rehearsal publishes its archives with --dry-run from another clone, as the release job does.
The tests run the commands of .releaserc.json and semantic-release itself against the fake npm
(archives of another version, of another commit, changed or substituted: nothing is tagged or
published), a partial publication then its recovery from the archives of another checkout, and
the wiring and the bash steps of the jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… CI runs

`@types/node` 26 needs `skipLibCheck: true` because of the types of thread-stream (used by pino):
it is now stated as a limitation of the supported setups in the known issues, the migration guide
and the checklist of the production guide, next to the strict setup with `@types/node` 24 that the
consumer check keeps checking.

GitHub keeps one pending run per concurrency group and cancels it when another one is queued: a
recovery dispatched while a run of main is in progress can be cancelled by the next push (and it
cancels a run of main that waits), which the release documentation now says, with what to check.

The README said that the packages require npm >=11.11.0: only the repository does, the packages
declare no npm version and require only Node.js >=24.14.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release rehearsal, the check of the packed files and the consumer check moved from the lint
job to the package job, which packs the archives that a release publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A compression removed the Content-Length of the body that it codes and let
Node.js frame the rest. Node.js remembers every removed framing header: once
binden had removed a stale Transfer-Encoding (e.g. copied from an upstream
response) or the application had removed one, the compressed body of an
HTTP/1.1 response was delimited by closing the connection, so that the next
pipelined requests of the connection were lost, and a stale Transfer-Encoding
written directly was sent in chunks to an HTTP/1.0 client, which does not know
them.

The compressed body is now framed explicitly: Transfer-Encoding: chunked for
HTTP/1.1 (also on the headers of HEAD, as for GET), and Connection: close
without a Transfer-Encoding for HTTP/1.0. Only the headers that are set are
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sendFile() and the dynamic ETag evaluated the preconditions against the body
as it is, and @binden/compression then sent the identity representation to a
request whose If-Match listed its strong tag, whatever the request accepted
(also with identity;q=0), and a range of the file as it is to a request that
accepted gzip. The decision of the compression came too late: it was taken
when the headers were committed.

A library can now choose the representation of a body before the core
evaluates the preconditions (kNegotiator, kRepresentation, internal to the
packages): the choice sends no header and starts no coding, and the commit of
the headers applies it. @binden/compression registers its decision, which no
longer has side effects until the commit. sendFile() and send() with the
dynamic ETag choose the representation, then evaluate the preconditions with
its ETag (weak once compressed: a strong If-Match fails with an empty 412,
without a compressor nor Content-Encoding), then the ranges, which only the
file as it is has (a compressed file is sent whole). A representation replaced
with 406 keeps that status whatever the preconditions are, and the file is not
read then. A stream that fails before its first chunk forgets the choice, so
that the error reply (or the 503 of a deadline) gets a decision of its own.

The dynamic ETag now evaluates every precondition as sendFile() does: If-Match
and If-Unmodified-Since fail with 412, and If-Modified-Since gets 304 with the
Last-Modified that the response may have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…compression

The compression only checked Accept-Encoding for the responses that it could
compress. A fixed format was sent whatever the request accepted (also with
gzip;q=0, identity), a response below the threshold, refused by the filter,
skipped, with no-transform or a range was sent as it is to a request that
refused identity, and an encoded response (e.g. a precompressed file) was sent
whatever the request accepted.

The decision now reads the acceptable codings first (RFC 9110, section
12.5.3), then whether the response may be compressed, then chooses the
representation. That is the acceptable coding that it prefers among the
offered ones (a fixed format is the only one offered, and identity is
preferred without Accept-Encoding or with an empty one), otherwise the
response as it is when identity is acceptable, otherwise 406. An encoded
response has no other version, so it is sent as it is when the request
accepts its coding (any coding without Accept-Encoding, none with an empty
one), and gets 406 otherwise. A response without a body (1xx, 204, 205, or an
empty body) never gets 406. Every response with a body gets the header
Vary with Accept-Encoding, as each depends on it.

The empty 412 of a failed precondition (of sendFile() and of the dynamic
ETag) no longer carries the Content-Encoding of a precompressed body, which
an empty body does not have; its 304 keeps it.

BREAKING CHANGE: a fixed format is used only when the request accepts it
(send Accept-Encoding, e.g. from the proxy that negotiates); a request that
refuses identity gets 406 also for the responses that are not compressed,
and a precompressed response gets 406 when the request does not accept its
coding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pression

The review reproduced four representations that the request had refused: a
fixed gzip format sent to gzip;q=0, identity; a body below the threshold and
one refused by the filter sent as they are to identity;q=0; and a file whose
strong validator met If-Match sent as it is to gzip, identity;q=0. One test
now replays them as they were reported, for GET and HEAD: identity, 406, 406
and 412.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…compression

The bullet about the 304 of a compressed response was wrapped in the middle
of a sentence, and the comment of the decision said that every response with
a body depends on Accept-Encoding, which the 500 of a failed filter does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The type of the `state` factory refuses a promise through a `then` property, which had no comment:
`npm run docs:build`, which refuses an undocumented export, failed on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Comment thread packages/binden/src/headers/accept-encoding.ts Fixed
Comment thread packages/binden/src/headers/authorization.ts Fixed
Comment thread packages/binden/src/headers/range.ts Fixed
Comment thread packages/binden/test/binden.test.ts Fixed
vansergen and others added 12 commits October 15, 2026 10:31
… binden

CodeQL found polynomial regular expressions in three parsers of request headers: quantifiers that
can match the same spaces or tabs backtracked on long runs of them (about 350 ms of CPU for one
header of 15 KB with Authorization or Range). The scheme of `Authorization` and the weight of
`Accept-Encoding` are split at their first space or `=`, and the elements of `Range` are trimmed
in linear time before a regular expression without overlapping quantifiers matches them. A test
bounds the time of the inputs that CodeQL reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test of `createSecureServer()` disabled the verification of certificates (CodeQL), because the
certificate of the tests named another host. It is replaced by a certificate for `localhost`
(and 127.0.0.1, ::1), which the client trusts as its own authority: a certificate for another
name, or one that is not trusted, now fails the test, which is bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…form

On GitHub, the tests failed where the platform differs from the machine they were written on:
- Node.js 24.14.1 has no `writeInformation()`: the test of the override was skipped, so its lines
  were not covered. A test now checks that, without it, an abandoned call is ignored and an
  allowed one throws a TypeError, as Node.js itself would.
- Windows takes a whole write while its send buffer has room: three tests that need a response
  that cannot finish before the client reads it are skipped there, like the others of the kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows job failed on three tests of behaviours that Windows does not have, which the other
platforms still check:
- the SIGTERM shutdown of binden: `kill("SIGTERM")` ends a process at once on Windows;
- the end of a coding in @binden/body: Windows resets a connection closed with unread data at
  once, so the client may lose the reply;
- the replaced target of a failed copy in @binden/multipart: Windows refuses to rename a file
  that is open, so the replacement cannot be staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows, Node.js writes an uncaught error with CRLF line endings, and the git of the release
script warned about CRLF conversions: three tests of scripts/release.mjs did not find the
messages they expected there. The tests normalize the line endings of its stderr, and the fixture
repository turns `core.autocrlf` off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tests in the pack rules

The second review of the pack rules slipped files through them. Only the last link of a
generated file was checked, while V8 follows the last COMMENT: a string or a template literal
that looks like a relative link hid an absolute `//# sourceMappingURL=` comment before it, and
a link followed by another token on its line (which V8 ignores) passed as well. Only `.js` and
`.d.ts` files were read, although Node.js runs TypeScript sources and bundlers and browsers
read `.jsx` or `.css` links, and the `file` of a map could keep the absolute path of the build.
Common credential files (`_netrc`, `.yarnrc.yml`, `.aws/credentials`, `.pgpass`, `.htpasswd`,
`.envrc`, `credentials.json`, `.docker/config.json`, `.kube/config`), keys (`.asc`, `.gpg`,
`.pkcs12`, `.p7b`, `.kdbx`, `.ovpn`), tests (`*-test.*`, `test.*`, `*.e2e.*`, `spec/`, `e2e/`,
`__fixtures__/`, `fixture/`, `testdata/`) and mocks (`mocks/`, `*.mock.*`) were not refused,
while the SSH key rule (`id_*`) refused modules such as `dist/id_token.js`.

Every link of every published file is now checked, in both comment forms, and its whole value
(to the end of the line or of the comment) must be one relative path to a published map; the
`file` of a map must be relative to it; the new names are refused without case, and the SSH
rule matches the key names only (`id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519`, with `_sk` and
`.pub`). The tests replay every case of the review, and V8 itself confirms which link it takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on wrote

The publisher checked the name, the version and the gitHead of the package.json inside each
archive, but nothing else of it: an archive whose package.json gained a postinstall script or a
dependency, listed in a consistent manifest with its integrity and digest, was published (case
S5 of the review), although npm installs and runs what that file declares.

`publish` now compares the package.json of every archive byte for byte with the one that
`scripts/release.mjs version` wrote into the workspace of its checkout, and `pack` does the same
with the bytes that `version` wrote before `npm pack` ran (a `prepack` script that rewrites the
manifest is refused there, not only at the release). npm packs package.json as it is on disk,
so the archives of the `package` job match what the release job writes from the same commit.
The fake npm keeps package.json as it is on disk, like npm, and can rewrite it like a prepack
script; the tests cover a script, a dependency, a bin and other formatting of the same fields,
in `pack`, `publish` (with and without --skip-existing) and the commands of .releaserc.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he release relies on

The recovery jobs ran for a dispatch from any branch: `recover-package` and `recover` checked
that the commit of the tag is on `main`, but not the ref of the run, and nothing in the
repository said that the `release` environment must admit `main` only. A run dispatched from
another branch runs the workflow of that branch, whose conditions may be gone, so the
environment (with npm trusted publishing bound to it) is what keeps such a run from publishing.

Both recovery jobs now run only for a dispatch from refs/heads/main. The workflow tests evaluate
the condition of every job for pushes, pull requests and dispatches from main, another branch or
a tag (each publishing job checks the ref itself, not only through the jobs it needs), and pin
that only `release` and `recover` can publish to npm, both in the `release` environment.

The README states the settings outside the repository: the `release` environment limited to the
branch `main` with required reviewers and no administrator bypass, npm trusted publishing of the
five packages bound to `ci.yml` and that environment (any NPM_TOKEN an environment secret only),
the protection of `main` with its required checks (`package` among them), the former
repositories archived, and the security features; the recovery is dispatched from `main`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…@binden/body

The zstd decoder of Node.js 24 stops after the first frame of a write, and accepts a truncated
frame. So a body of concatenated frames (valid, RFC 8878) got a 400 when its frames came in one
chunk, and a truncated body was parsed as a shorter one. The parser now finds the frames itself
and gives one frame at a time to a single decoder, so that concatenated and skippable frames
decode the same on every supported version, and a truncated frame is a 400. One `limit` counts
the content of all the frames, and `encoded_limit` and `verify_encoded` the body as received,
once. A body with a coding and no byte at all is a 400 for zstd too, as for the other codings,
while a frame of an empty content is an empty body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`identity` means no coding (RFC 9110, section 8.4.1), and @binden/multipart and the parser of
2.0.1 accept it, but the parser refused `Content-Encoding: identity` with a 415. It is now left
out of the list of codings, in any case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…@binden/multipart

A refusal destroyed the request once the response had started, and a send of a stream that still
waits for its first chunk counts as started: at the deadline the client got a closed connection
instead of the 503, and the send failed. The refusal now destroys the request only once the
headers are sent. Before, it leaves the send to the framework, which cancels it at the deadline
and replies, or sends it with `Connection: close`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in @binden/multipart

A parse that goes on after the cleanup of the request (e.g. not awaited before the reply) refused
its files with "A file has not been handled", whose cause only said that the request had been
completed. The disk storage now refuses them with a 500 that tells why (the cleanup has started,
and would not remove them), also when the cleanup stops a file that is being written. The README
ties the refusal to the cleanup, not to the end of the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vansergen and others added 30 commits October 17, 2026 15:43
…source in binden

The guard of a source does not report an error that a send hears, since it
fails that send. The stream-like adapter counted its listener, but the send
of a Node.js Readable did not. A Readable sent to one response and released
by another (e.g. to HEAD) whose destroy then failed rejected the live send
with that error and was also logged late by the guard of the release
(R1 R20-4).

The listener of error of the send now counts as hearing from when it is added
until its removal, also when that removal throws, and a kept listener stays
counted. An emitted error that fails the send has decided it, so a guard
whose listener comes after the one of the send does not report it either. A
send that has been stopped by another writer reports such an error itself,
once, since its guard no longer does while the send hears the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at decides its send in binden

A Node.js Readable whose code throws one object for every listener of its
close rejected its send with that object, and the guard of the send, which
the same code then refused, logged it again as a failed release (R3.20-4).

A failure that binden finds itself (own) is now recorded as decided when the
send settles, before its guard is installed, also when the guard is refused
and the source is destroyed without it. guard() no longer gives a decided
error to its refusal callback. The send of a source that has been destroyed
or has errored before passes the error that it settles by to its guard,
whose refusal does not log it either: the rejection, or the late report once
the response has ended, tells it once. A primitive cannot be recorded, and
is still logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le in binden

A stream-like source whose code refused its guard, or threw in a pause or a
removal of a listener while send() released it, was logged with the message
of a late error. A Node.js Readable logs the same failures as "The release
of a stream has failed" and "The removal of a listener of a stream has
failed" (R2 N6a).

The response now passes its own logs into readStreamLike() and
releaseStreamLike() (IStreamLikeLogs), so both kinds of sources log a release
alike. Those logs hold the response, and no listener of a guard holds them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at its finish in binden

A send counts as complete for the client once its declared Content-Length
bytes are flushed, so a client that goes away afterwards does not fail it.
For a declared length of 0 that held at once, before anything was written:
a client that went away before the head left the send resolved although the
headers had never been sent (R1 R20-6).

An empty declared body is now complete only once the response has finished.
Before that, a close rejects the send as a premature close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g writes in binden

The JSDoc said that a send whose response closes before it has settled
rejects as a premature close. When writes are pending, Node.js and the
system usually report the error of the connection first, through the
callback of a pending write: EPIPE, ECONNRESET or ECANCELED, depending on the
platform and the timing (R1 R20-3, R3.20-1).

send() and #pipe() now say so: the send rejects with the error of the
connection when a pending write fails first, otherwise as a premature close
(or resolves once its body is complete for the client). A release of its
source still never decides it. ffba1b2's claim is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts close in binden

The guard of a stream-like source left on the turn after its close. Sources whose state has no
closeEmitted (readable-stream 2 and 3, through2 4, plain emitters) still emit errors after
their close, e.g. an SSE feed written on a timer once binden has destroyed it at the deadline or
once the client has gone away (ERR_STREAM_DESTROYED, "write after end"), a second
destroy(error), or any error of an emitter. Without a listener of the application, the process
crashed, because of binden's own cancellation; Node.js pipeline() keeps its listeners and
survives the same.

The guard now leaves only when hasClosed() tells that the source has closed. Otherwise it stays
as long as the source lives, holding only the report of a late error, which logs each later
error once. A fixture without process handlers checks the reproduced cases, and a GC fixture
that the kept guards do not retain the responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n binden

A stream-like source that refuses every listener of its error (e.g. a listener of its
newListener that throws) was still paused once binden could not release it. Its pause() is
code of the source, which may emit an error asynchronously: with no listener in place, that
crashed the process.

Such a source is now left fully untouched, both when it is sent and when it is released
(HEAD, 204, 304, 1xx): neither its pause() nor its destroy() runs, and the response logs that
it could not be released. A source whose guard or send listens is still paused or destroyed
under that listener, so an asynchronous failure of its pause is reported like one of a destroy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on a failed removal in binden

At settlement, the adapter installed the guard of the source before it detached the listener
of error of the send. The guard's on() runs code of the source (e.g. a listener of its
newListener) that may emit an error at once: it reached the settled send, which cut a complete
reply (and lost the next response of the connection) or dropped the error. The listener of the
send is now detached first, so such an error is reported once as a late error.

A removal of that listener that removed it and then threw left the source counted as heard:
its guard stayed silent and a later error was never reported. The count now drops in a finally,
whether the removal threw before or after removing the listener; one that is left is detached
and reports a later error itself only when the guard does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eciding error in binden

A destroy() of a stream-like source that threw inside the destruction hook of its readable
escaped into Node.js, which then destroyed the readable with that throw: the release failure
replaced the error that decided the send (e.g. RESUME FAILED became RELEASE FAILED). The hook
now catches it, logs it as a failed release, and completes with the deciding error.

releaseStreamLike() (HEAD, 204, 304, 1xx) called destroy() or pause() unprotected: a throw
rejected the send, so HEAD got a 500 and a 1xx send lost its TypeError. It now logs the throw
and goes on, as for a Node.js Readable.

Code of the source that throws a value whose stack getter throws made readable.destroy() throw
inside Node.js's checkError: at setup the listeners of the send stayed and a later error of the
source crashed the process, in pause() the throw escaped into its emit("data"). The readable is
now destroyed defensively, with an Error that carries such a value as its cause, so the
listeners are always released and the send rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sistent in binden

A stream-like source destroyed with an error before the send (its errored, e.g. of
readable-stream 4) rejected as a premature close, its real error logged only as a late error.
It now rejects with that error, as a Node.js Readable does, and is not destroyed again.

The reason with which a cancellation destroys a stream-like source (the deadline's, the
client's, or the premature close of a falsy value that its code threw) was never recorded as
decided, so its guard logged the very error that the send had rejected with. It is now recorded
before the destroy.

When code of the source ended the response while the send read it (an errored getter, or its
own on() that throws), the adapter recorded the error as decided before the send had decided
on it, so the send resolved and nothing was logged. The adapter no longer decides what the
response decides; the response's late report of its readable is recorded for the source itself,
so it is logged once. While the send decides, no guard reports that error, neither from a
destroy that emits it at once nor from a refusal of the guard's listener that throws it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce failed mid-send in binden

A data event of a stream-like source that carried null was pushed into the adapter's readable,
where push(null) is its end: the send resolved with a truncated body framed as complete, and
undefined was silently dropped. A chunk that is neither a string nor bytes now destroys the
readable with a TypeError (ERR_INVALID_ARG_TYPE), whatever it is, so the send rejects and a
committed response is cut.

A stream-like source that emitted an error mid-send without being destroyed (e.g.
readable-stream 4 with autoDestroy: false) was never released, unlike a Node.js Readable. It
is now released under its guard once the send has failed, destroyed without its error (which
it has emitted already), or paused without destroy().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The response was the only caller that passed emitted as true, to leave an error that the
source emitted to its guard. Now that the error listener of a send counts as hearing its
source (hear()), the guard never reports an error that a send hears, so that branch is dead
and the send reports such an error itself, once. reportLate(source, error, report) now reduces
to the report of a late error once.

The two call lines of the response (the stop path of a send, and the send of a source that
cannot be read) drop the argument, and the unit test that only kept the dead branch covered is
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…21 in binden

The send() paragraph tells which streams keep their guard for life, that a source without an error
listener is left untouched, that the listener of error comes first, how errors emitted while a guard
is installed or heard by a send are reported, the messages of stream-like release failures, the
errored and falsy cases of stream-like sources, chunks that are not bytes, and empty bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of binden

The README described a stream that refuses the listener of close of its guard like one that refuses
its listener of error. They differ: a refused error listener keeps the listener of error of the
send and destroys the source without the deciding error, while a refused close listener is logged
once and leaves the listener of error of the guard in place for the life of the source (the guard
no longer hears its close), the send's listener removed, and the source is still destroyed with the
deciding error (R3's close-refusal2.mjs node|like error|close on 6b3bf40). The refusal record is
now stated once for both.

MIGRATION-4.md said that the send of binden 2.0.4 never settled when the client went away first,
which is false for a stream that fails by itself: the echo of a request whose client goes away
rejected with ECONNRESET in 2.0.4 (mig-echo-gone.mjs), while an ordinary stream stayed pending
(mig-gone.mjs 64).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…line to race in binden

stream-like-destroyed.ts destroyed /ended on a 2 ms timer under the 50 ms deadline of the
application, and its premature close is decided on the turn after that destroy. A stall of the
process once the timer is scheduled lets the deadline pass first, which replies 503 instead of 500
(the macOS flake of round 21; R3's stall2.mjs with STALL=60: "503 204 500 500 500 503").

Every request but /cancelled, the only one that waits for its deadline, now removes it
(context.setTimeout(0)); the others are decided at once, or on the turn after the destroy of /ended.
That destroy stays on a timer rather than a setImmediate(), as a0c71d6 triggers its sources: its
premature close (a setImmediate() of the send) then follows in the same turn of the event loop,
before the timer of the failure of its destroy 20 ms later. From a setImmediate(), a stall of 25 ms
or more right after the destroy let that failure come first, and the send rejected with it (the
fixture timed out waiting for its late error).

A 60 ms stall right after the trigger is scheduled, inside it once it has destroyed the source, or
at the start of each middleware but that of /cancelled, gives the expected output on Node 26.11.1,
24.16.0 and 24.14.1, where the old fixture printed 503 for /ended on the first and the last. Under
CPU burners the test passed 40/40 on Linux (node:26.11.1, also with two burners per CPU, and
node:24.14.1) and 40/40 on macOS (26.11.1 under burners, 24.16.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
V8 formats the stack of an error once it is first read, and until then its frames hold their
objects, such as the receiver of each method (the exchange or the response). A source that keeps
the error it is destroyed with (minipass keeps its last emitted error) or an application that keeps
the error its send rejects with then retained the request and the response for as long as it lived.

The reason of an aborted signal (a BindenAbortError), the premature close, the length mismatch and
the refusal of a pipe now have their stacks formatted as binden creates them, as Node.js does for an
error it destroys a stream with. A prepareStackTrace of the application that throws leaves the
stack to it. The argument errors of a call (a TypeError or a RangeError) are thrown or rejected to
its caller, as by any Node.js API, and are not changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ue in binden

When code of a Readable throws a value whose stack getter throws, the send rejects with it and
destroys the source with it, but Node.js reads the stack of the error a stream is destroyed with,
so the destroy threw before anything was destroyed: the release was logged and the source (e.g. a
file stream) stayed open with its descriptor.

The release now falls back, as the stream-like adapter does, to an Error that carries the thrown
value as its cause, whose stack is formatted and which is recorded as decided, so that the guard of
the source does not log it once the source emits it. A destroy that throws once the source is
destroyed, or one without an error (of a send that has resolved), is logged as before and not
called again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in binden

The pending decision of a stream-like send was one value per source: a send of the same
source that its code starts while another one decides (e.g. from its own destroy) deleted
it on return, and the error that the source then emitted was reported as a late error,
although the outer send still rejects with it (Codex, round 21; R2-1). decide() now runs a
call while a send decides an error, counted per source and error, so that an inner
decision with the same error that ends first leaves the outer one pending.

Only an object is known as decided: a primitive that decided a stream-like send and that
its code throws again for the listener of its guard was not logged, unlike for a Node.js
Readable, while the README says that a primitive is logged (R3.21-2). isKnown() is the
one check of the guard, of the other reports, and of the refusals.

This also adds the interfaces that the response needs from the adapter. decidedFor()
records an error that a source has emitted and that has decided its send for that source
only (the record of the reports), so that the same object of the application can still
be a late error of another source (R1 F2); a refusal of the guard of that source with it
is not logged either. resolved() tells a readable of a stream-like source that its send
has resolved, so that it destroys its source without an error, never with the reason of
a cancellation (R2 R21-H3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…binden

A Readable destroyed with an error before its send rejects at once with that error, but the error
was decided only after its guard was installed: code of the source that emitted it meanwhile (a
listener of its newListener) had it logged as a late error, although the send rejects with it. The
pending decision is now in place around the installation of the guard (decide() of the adapter),
which replaces the local filter of the refusals of the guard.

An error that a source emits and that decides its send was recorded as decided for every source,
so that one object of the application that failed one send (e.g. a shutdown error that it destroys
each of its streams with) was never logged as a late error of another source. It is now recorded
for its source only (decidedFor()), as is the error that a destroyed Readable rejects with. The
global record stays for the errors that binden creates itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… has heard it in binden

A send whose response another writer has ended reported an error that its source emitted at
once, as a late error. One source sent to such a response and to a live one (e.g. a shared
PassThrough) then had the error logged as late when the stopped send listened first, although
the live send rejected with it in the same event: the order of the listeners decided the log.

A stopped send now reports the error of its source on a later turn, once the event that may
have brought it has reached every listener, so that a live send of the source has decided it
first, and it is not logged in either order. The close of a source destroyed with an error that
its _destroy() swallows brings its errored to every listener as well: it is handled as an
emitted error, decided for the source by the live send and reported later by the stopped one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lves in binden

A Node.js Readable is destroyed without an error when its send resolves, but a stream-like source
was destroyed with the reason of a cancellation: a premature close once another writer had ended
the response (e.g. the 406 of a compression), or the abort of the client once a complete declared
length had been sent and the client closed. Its own error listeners and its errored then reported
a failure for a successful response.

The send now tells the adapter (resolved()) before it releases its source when it resolves, and
when a chunk comes once another writer has ended the response, so that the source is destroyed
without an error, as a Readable is. Native pipeline() errors such a source: binden keeps parity
with its own Readable path instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lled in binden

The running-error path installed the guard of the source through settle() before the
error that fails the send was decided, and the destroyed path installed it before it read
and marked the errored of the source. Code of the source that threw that error again for
a listener of the guard (e.g. a listener of its newListener) was logged as a refusal, and
one that emitted it then was reported as a late error, while the send rejected with it
(Codex R21-C2, R1 F1). The same held for an error that the readable is destroyed with
(e.g. what a pause() of the source has thrown), whose record came after the guard.

Each path now runs its guard installation inside decide(): fail() decides the error of the
source around settle(), the destroyed path reads the errored first and installs the guard
inside the decision (also when that read throws, so that the guard still listens), and
the destroy hook of the readable decides the error that it is destroyed with around the
whole release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd for its source in binden

The destroy hook of the readable of a stream-like source recorded every error that it
destroys the source with as decided for every source: one error object of the application
(e.g. what a pause() of the source has thrown, also used to destroy other streams) was
then never reported as a late error of another source, the same defect as R1 F2 for an
error emitted by a Node.js Readable.

An error that the readable is destroyed with is now recorded for its source only (see
decidedFor()); only the reason of a cancellation, an error of binden, stays recorded for
every source (see decided()).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…royed with in binden

The adapter destroyed a stream-like source with the reason of a cancellation (the reason
of the signal of the request, or a premature close) whose stack V8 had not formatted yet.
Such a stack keeps the objects of its frames, so that a source that keeps the error it
is destroyed with (e.g. minipass 7, which keeps the error that it emits), and that the
application keeps, retained the request and the response for its life (R2 R21-H1: 60 of
60 alive after a collection).

The adapter now reads the stack of that reason once before the source has it (a stack
that cannot be read is handed over as it is). The errors that the readable is destroyed
with are formatted by Node.js already, which reads their stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…binden

A chunk of a stream-like source that the push() of its readable refuses by throwing (e.g.
a view of an ArrayBuffer that has been detached, which Node.js cannot wrap in a Buffer)
threw into the emit("data") of the source, out of code of the application, while the
send went on (R2 notes).

The push() is now protected as the pause() that follows it: what it throws fails the send
through destroyWith(), as for any other failure of the source, which is destroyed with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e in binden

The logs of the adapter call the logger of the application. One that threw inside the
destroy hook of the readable (e.g. for a removal of a listener, a destroy of the source,
a refusal of its guard, or a source left untouched) escaped from it: Node.js then
destroyed the readable with the throw of the logger instead of the error that decides the
send, the other listeners of the send stayed on the source, and the source was neither
destroyed nor paused (R2 notes). The deferred removal of the listener of error, on a turn
of its own, crashed the process when its log threw.

Every log of readStreamLike(), and the notice of a source left untouched, is now quiet: a
log that throws is ignored, and the release goes on, as binden already does for a logger
whose child() throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in binden

A guard that leaves on the turn after the close of its source removed both of its
listeners in one try, and reported a removal that threw as a late error of the source
("A released stream has failed"), while every other removal is logged as "The removal of
a listener of a stream has failed"; after the first throw, its listener of close was left
on the source (R3.21-6, for stream-like sources and Node.js Readables alike).

Each of its listeners is now removed on its own, and a removal that throws is logged with
the message of a removal by the report of the source, which holds only the logger: the
late report of a request now takes a message of its own (IOwnership.released). A logger
that throws then no longer escapes from the turn of the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…treamLike() in binden

The JSDoc of readStreamLike() still said that a removal of a listener that throws is
reported (see ISourceReport), while it is logged by its logs (see IStreamLikeLogs) since
round 21 (R3.21-5). It now says so, and tells that the reason that a cancelled source is
destroyed with has its stack formatted, and that a source whose send has resolved is
destroyed without an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or its source in binden

Three follow-ups of round 22 (items 2 and 3). The adapter formatted the reason that it
destroys a stream-like source with by a helper of its own: it now uses formatStack() of
the exchange, the one helper of binden (no import cycle: the exchange imports neither the
adapter nor the response). The other errors of the adapter that a source or a send gets
(a chunk that is not bytes, a premature close) are formatted by the destroy() of Node.js,
which reads their stack before anything has them: the tests now check it.

The framework created the 503 of the deadline, which the error handler is given, and the
500 of an error handler that has not settled in time, which its pending sends reject with,
without formatting their stacks: an application that kept one retained the frames of its
creation (R2 R21-H1). Both are now formatted.

A value that code of a Node.js Readable throws (its own resume(), at once or once the
response drains, its pause(), its on(), or a listener of its newListener) was recorded as
decided for every source, although it is an object of the application, which may still
be a late error of another source (as R1 F2 for an emitted error). It is now decided for
that source only (decidedFor()); an error of binden or of Node.js (e.g. a body longer
than its Content-Length) stays decided for every source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eleases of round 22 in binden

The Lifecycle section tells that the errors binden hands over have their stacks formatted, and the
send() paragraph tells when an error is decided before the guard and for which stream, that a
stopped send logs on a later turn, that a resolved stream-like source is destroyed without an
error, the fallback destroy of a Readable, unpushable chunks, removals of a guard and throwing
loggers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants