- connectWebSocket(dialer, url, { protocols }) now tested: verifies the
Sec-WebSocket-Protocol request header is sent as a comma-joined list
- Basic-auth credentials in the ws:// URL now tested: verifies Authorization
header is set correctly
- upgradeWebSocket(req, res, { protocols }) now tested: verifies the server
echoes Sec-WebSocket-Protocol response header from protocols[0] (server.ts 100%)
- Abrupt transport close (no Close frame) now tested: exercises the
catch { break } path in WebSocketConnection's async iterator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs in fetch.ts caused the upgraded connection to be closed or
reassigned before connectWebSocket() could hijack it:
1. prepareRequest() checked Connection !== "upgrade" case-sensitively.
A request with Connection: Upgrade (capital U) was silently rewritten
to Connection: close, making ClientConnection auto-close the transport
after receiving the 101 (shouldClose returned true, no body → immediate
close). Fixed by lowercasing before the comparison.
2. rawFetch() called onPrevBodyFinished().then(done(false)) even for 101
responses. A 101 has no body so the promise resolves as a microtask,
firing done(false) — and thus pool.releaseConnection() — before the
caller can call connectWebSocket(). With keepAlive:false the pool
closes the connection immediately; with keepAlive:N it puts it back in
the idle pool where a concurrent waiter can grab it. Fixed by calling
done(true) synchronously for 101, which calls rejectConnection()
instead, removing the connection from the pool without closing it.
rawFetchStream() had the same issue in its finally block when the
caller breaks before calling connectWebSocket(); fixed likewise.
Tests added in websocket.test.ts covering all four affected paths:
- fetch() + connectWebSocket(res, key)
- makeFetch(keepAlive:false) + connectWebSocket(res, key)
- pool race: second request after WebSocket hijack gets a fresh conn
- fetchStream() + connectWebSocket(r, key) inside the loop
- makeFetch().stream + connectWebSocket(r, key)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the ClientConnection overload. Replace with:
- connectWebSocket(dialer, url, options?) — accepts ws:/wss:/http:/https: URLs,
dials via RawDialer, handles Host header, basic-auth, and port defaulting
(mirrors the fetch() API surface).
- connectWebSocket(res, key) — promotes an already-obtained 101 ClientResponse
to a WebSocketConnection; useful with fetchStream() or any pooled fetch.
The common accept-key verification and hijack logic is shared internally.
Tests updated: withWebSocketPair now uses loopbackListener + dialer URL;
new URL-validation and response-overload suites added.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server: upgradeWebSocket(req, res) validates the HTTP Upgrade handshake
and returns a WebSocketConnection backed by the hijacked transport.
Client: connectWebSocket(conn, target) sends the Upgrade request, verifies
Sec-WebSocket-Accept, and returns a WebSocketConnection.
WebSocketConnection is an async iterable that yields text/binary messages.
Frame codec handles masking, fragmentation, and the close handshake.
Hashing uses crypto.subtle (Web Crypto API); masking uses
crypto.getRandomValues — no external dependencies.
Exported as @webnet/http/websocket (separate entry point, fully
tree-shakeable from bundles that don't use it).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add /* c8 ignore start/stop */ around ContinueBodyReader.onFinish: the
method satisfies the BodyReader interface but has no external callers
because ServerConnection.#prevBody always holds the raw body reader, not
the ContinueBodyReader wrapper. Achieves 100% coverage across all files.
Update AI disclosure to accurately name fetchStream() and f.stream()
alongside requestStream() as the streaming work done in this branch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix inline import() in connection.test.ts to use a top-level import type.
Exports: add ClientInformationalResponse, fetchStream, FetchStream to
client/index.ts and the root index.ts.
Coverage improvements:
- server/objects: sendInformational throws on bare response; uses
"Unknown Status" for unrecognised status codes
- client/connection: requestStream throws on hijacked connection;
drains previous body before streaming; closes on parse error;
closes immediately when shouldClose and response has no body
- client/fetch: early break from fetchStream before final response
Remaining gap: ContinueBodyReader.onFinish (server/connection.ts:57-62)
is a required BodyReader interface method with no external callers in
the current server code path — #prevBody always holds the raw reader,
not the wrapper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
interim: add "skip" as an explicit value matching undefined behavior, so
callers can be explicit at the call site.
requestStream + hijack: the try/finally in requestStream now checks
this.#hijacked before closing, so breaking out after calling hijack()
on a 101 response keeps the connection open rather than closing it.
fetch: add fetchStream() standalone function and f.stream() method on
makeFetch() results. Both return AsyncGenerator<ClientResponse> and
forward the inner requestStream generator through pool management:
- inner generator cleanup (innerGen.return()) is called in finally to
ensure the connection closes on early break even when the inner loop
is abandoned
- on early break (before final): pool.rejectConnection()
- on normal completion without hijack: onPrevBodyFinished triggers
pool.releaseConnection() or rejectConnection() on error
- on hijack: the hijack listener handles pool.rejectConnection()
fetch() and makeFetch() now forward interim:"collect"/"skip" through
rawFetch → conn.request(), which already handles them.
prepareRequest() helper extracted to share URL parsing, validation,
and header setup between rawFetch and rawFetchStream.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Default mode: the client now reads past interim 1xx responses
(100/102/103 and unknown 1xx, but NOT 101 Switching Protocols
which terminates the exchange) and returns the final response.
This fixes the longstanding TODO test.
Collect mode (interim: "collect"): interim responses are captured in
res.informational as ClientInformationalResponse objects with full
header access.
Stream mode: requestStream(req) is a new async generator that yields
each interim response and the final response as they arrive, enabling
callers to act on 103 Early Hints before the body is ready. Breaks
out of the loop early (e.g. before the final response) safely close
the connection via a try/finally guard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sends 100 Continue lazily when a handler first reads the request body
and the request carried Expect: 100-continue. If the handler returns
without reading the body the connection is closed rather than
attempting to drain (which would deadlock waiting for a body the
client has not yet sent).
Adds res.sendInformational(status, headers?) on ServerResponse so
handlers can push arbitrary 1xx responses (e.g. 103 Early Hints) to
the client before the final response is sent.
Also registers 100/102/103 in statusCodeProperties with responseBody:
false so Content-Length is not added if the final response status
happens to be a 1xx code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix two inline RawTransport imports in server/connection.test.ts (the
type was already imported at the top of the file).
Add prependTransport suite to common/buffer.test.ts covering every
getter and method: read() prefix-then-base, closed, close(), readEnded,
whenClosed, remoteAddr, localAddr, write(), and both branches of the
halfClose conditional.
Add a client/connection test where the server sends the 101 response
and raw protocol bytes in the same write, leaving bytes in the
ReadBuffer when hijack() is called. This exercises the prependTransport
path inside #takeTransport().
Add a fetch() hijack test that sends a 101 response through the full
fetch stack. This exercises the poolDone guard in rawFetch: hijack()
fires the synchronous listener (done(true)) before the
onPrevBodyFinished microtask runs done(false), which hits the guard and
is correctly a no-op.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Guard: send() and request() now throw "Cannot use a hijacked connection"
if called after hijack(), preventing accidental state corruption.
Pool timing: the previous onPrevBodyFinished().then(releaseOrReject)
approach queued pool management as a microtask that ran before the
caller's await-continuation, meaning hijack() hadn't been called yet
when the pool decision fired. The fix injects a synchronous hijack
listener via setHijackListener() that calls pool.rejectConnection()
immediately when hijack() is invoked. A poolDone latch ensures only
one of the two paths (listener or body-finish callback) updates the
pool, regardless of call order or async gaps between fetch() and
hijack().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hijack({ sendResponse: false }) flushes any pending write-buffer bytes
without sending the response headers/body. This is intended for use
with the upcoming 1xx support: a handler can send a 101 Switching
Protocols as an interim response via the 1xx mechanism, then call
hijack({ sendResponse: false }) to take ownership of the transport
without double-sending the response.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ServerResponse.hijack() sends the current response then returns the
underlying RawTransport so handlers can switch to a different protocol
(WebSocket, raw TCP, etc.) without the server loop taking over again.
ClientResponse.hijack() returns the raw transport after a 101 response
so the caller can take over the connection for the new protocol.
Both variants prepend any bytes the ReadBuffer had already read ahead,
ensuring no data is lost. The server's handle() loop exits cleanly on
hijack and leaves the transport open. The pool integration in fetch.ts
rejects hijacked connections rather than returning them to the pool.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers: opening PRs with tea and the WIP: prefix convention, and
updating the README AI disclosure at the end of every branch.
CLAUDE.md is symlinked to AGENTS.md for Claude Code compatibility.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous disclosure significantly understated Claude Code's
involvement. It contributed the package scaffolding, a major transport
layer review and bugfix pass, the timeout feature, parse error message
improvements, and the full test suite — not just the initial empty
package and light spec guidance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The packages/http test suite was mostly generated by Claude Code, which
also surfaced and fixed three bugs during that work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The slot waiters and global waiters are Promise resolve/reject callbacks.
Promise callbacks cannot throw, so the try/catch around them was dead
code that also required c8 ignore annotations to suppress coverage gaps.
Removed both wrappers and moved the errors array to where it's actually
needed (connection close errors).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Excluding unit tests via an env flag doesn't make sense; the flag only
created confusion. RUN_INTEGRATION=false (via test:unit) is the only
useful gate. Removed the export from flags.ts and the { skip } options
from all unit test suites.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These are API-boundary parameter checks, not internal invariant
assertions. TypeError is the correct error type and the original message
communicates the problem clearly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prettier expanded two compact try/catch blocks from 1 line to 5 lines
each, and split an if/throw across two lines — bumping next 3 → next 5
for the shutdown() catch bodies and next → next 2 for the setTimeout
invariant guard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New tests:
- UnpooledDialer: exercise dialTls success path
- PooledDialer.rejectConnection: cover timeout.has branch by rejecting a
previously-released connection; also fixes a bug where the connection
was not removed from slot.idle before decrementing slot.alive
- PooledDialer.shutdown: cover the global-waiter-races-shutdown path
(releaseConnection fires ok() then shutdown sets #shutdown before the
microtask continuation runs)
Broken Invariant annotations:
- Standardise all invariant error messages to "Broken Invariant: ..."
- Add /* c8 ignore next */ to guards that TypeScript enforces on callers
or that cannot fire under correct internal state:
pool.ts: !connection checks, alive<0, idle>alive, not-in-idle-after-
setTimeout, catch blocks around Promise resolvers, allSettled error
collection, #notify's !slot guard
buffer.ts: slice-returned-fewer-bytes guard
node/transport.ts: string-chunk guard, closed-with-buffers branch,
socket write error callback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover previously missing branches:
- common/pair.ts: PairSync.close() idempotence and getA()-after-close
- common/reader.ts: _triggerFinishOk/Ko catch blocks (handler throws),
BasicBodyReader #chunks pre-buffer path (slice returns multiple arrays),
ChunkedBodyReader already-closed guard and truncated-chunk sanity check
- common/buffer.ts: WriteBuffer.flush() partial write of a second buffer
- client/objects.ts: method defaults to POST when body is truthy
- client/fetch.ts: rejectConnection called when conn.request() throws
- node/transport.ts: fast-fail read() path with null #readError (socket
destroyed without error event)
- server/connection.ts: console.warn for missing requireHeaders entry,
non-TimeoutError parse error propagates out of handle()
Result: 100% functions, 97.81% branches — remaining gaps are dead code
(defensive throws/catches that cannot fire under normal API usage).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds full test suites for all implementation files and fixes two transport bugs
discovered during testing:
- fix(loopback): `#maybeEnded` checked `this.closed` (requiring BOTH ends closed)
instead of just `#readClosed`; a pending read would hang after the peer closed if
the local write side was still open. Also add fast-fail in `read()` when
`#readClosed` is already set.
- fix(node): same `#maybeEnded` bug as loopback. `close()` used `socket.end()`
(FIN — waits for peer FIN) instead of `socket.destroy()` (RST — immediate), which
caused the test suite to hang. Add `#readError` field so errors seen before
`read()` is called are preserved for the fast-fail path.
New/extended test files:
- src/client/fetch.test.ts — fetch() and makeFetch() integration suite
- src/client/pool.test.ts — UnpooledDialer and PooledDialer (incl. TLS throw paths)
- src/client/connection.test.ts — extended with more parse/request edge cases
- src/loopback/transport.test.ts — loopback pair, listener, halfClose, error paths
- src/node/transport.test.ts — NodeDialer, NodeTransport, NodeListener (incl.
buffered-data path, server error event, dialTls ECONNREFUSED)
- src/server/objects.test.ts — ServerRequestImpl, ServerResponseImpl
- src/server/router.test.ts — extended with optional-param and global middleware
Coverage result: 100% functions, ~99.3% lines, ~97.1% branches (remaining branch
gaps are V8/tsx source-map desync artifacts, not missing test paths).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix vacuous `!clientTransport.closed || conn.ended` → `serverTransport.readEnded`
- Move HTTP/1.0 success-path tests out of the parse error suite
- Fix fire-and-forget `.then()` in loopback whenClosed test (now awaited)
- Add CRLF split across chunk boundary, 3-buffer slice, multi-buffer forward tests
- Add negative chunk size and post-close onFinish handler tests for readers
- Add non-null body with bodyAllowed=false writer test
- Add maxTargetLength boundary, HEAD wire-level, and body auto-drain server tests
- Add global middleware on 404/405 and HEAD-in-Allow router tests
- Add setOptions effect and 1xx-todo client connection tests
- Add TLS path and multiple concurrent global waiters pool tests
- Add new src/client/utils.test.ts covering combineClientConnectionOptions
One test marked { todo }: 1xx interim responses not yet implemented.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add src/test-helpers to tsconfig.json exclude so flags.ts is not
compiled into dist. Add a second --test-coverage-exclude glob to both
coverage scripts so the helper does not appear in the coverage report
or skew aggregate numbers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Change RUN_INTEGRATION default to true so npm test / test:coverage /
test:watch run the full 284-test suite (unit + integration) by default.
RUN_EXTERNAL remains independent and must be set explicitly.
Add test:unit, test:coverage:unit and test:watch:unit convenience scripts
(RUN_INTEGRATION=false) for when only the 226 unit tests are wanted.
Remove test:all, which is now redundant with the new defaults.
Default coverage: 90.9% lines / 92.8% branches / 94.1% functions.
Unit-only coverage: 77.5% lines / 94.8% branches / 89.6% functions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses Node's built-in --experimental-test-coverage with tsx as an import
hook — no extra dependencies required.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers common/{buffer,connection,headers,objects,reader,utils,writer},
loopback/transport, server/{connection,router}, and client/{connection,pool}.
All tests use loopbackTransportPair for in-memory transport — no network
access required. Extends the existing server/connection.test.ts with
send() and handle() suites.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the last chunk pushed bodyLength over the limit, the error was
thrown inside the try/catch and then caught — but because body.closed
was already true at that point, the catch block hit `continue` and
discarded the error silently.
Move the maxBodyLength check (and the yield) outside the try/catch so
only the awaited read is guarded, not the enforcement logic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bugs:
- ReadableHttpImpl.bytes() was copying data byte-by-byte; use set()
- NodeTransport and LoopbackTransportHalf were not clearing #errcallback
after a successful read(), causing errors between reads to be silently
swallowed; also gate #maybeEnded to only fire when a read() is pending
- ServerConnection.handle() was draining the request body after closing
the connection; skip the drain entirely on close (it only matters for
keep-alive reuse), and close after draining otherwise
- PooledDialer threw "Pool full" / "Per-origin full" instead of queuing
waiters; implement queuing via slot.waiting and a new #globalWaiting
list; also fix #shutdown never being set to true in shutdown(), and
reserve the slot before the async connect to prevent races
Code quality:
- shouldClose() was comparing Connection header values case-sensitively;
RFC 7230 requires case-insensitive comparison
- Headers._normalized was public (convention only); make it protected
- Headers.headers returned the original wire-case record while
MutableHeaders.headers returned lowercase keys; unify both to return
from _normalized so ServerRequest.headers and ServerResponse.headers
are consistent (both lowercase)
Enhancements:
- RawTransport: add optional halfClose(), readEnded, whenClosed,
remoteAddr, localAddr; NodeTransport and LoopbackTransportHalf
implement all applicable members; addresses cached at construction
before the socket handle can be invalidated
- RawListener: add optional addr; NodeListener exposes the bound
address (critical when listening on port 0)
- LoopbackTransportHalf: internalize _closeFn/_writeFn as constructor
parameters stored in private fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>