2 Commits
Author SHA1 Message Date
codingetandClaude 175a6a14d2 docs: update AI disclosure for lint fix and websocket test coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:17:55 +00:00
codingetandClaude 02912f25be fix(lint): resolve prefer-const in server/connection + add varsIgnorePattern for _
- Refactor HijackFn to accept res as first parameter, eliminating the
  let res! forward-reference pattern that triggered prefer-const
- Add varsIgnorePattern: "^_" to ESLint config so intentionally-unused
  loop variables (_) are not flagged alongside the existing argsIgnorePattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:17:15 +00:00
5 changed files with 198 additions and 6 deletions
+1
View File
@@ -77,3 +77,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **`packages/http``hijack()` on server and client responses**: the `hijack()` method on `ServerResponse` and `ClientResponse`, the `ReadBuffer.drain()` helper, and the `prependTransport()` utility were implemented by Claude Code
- **`packages/http` — 1xx informational response support**: implemented by Claude Code. Server side: automatic `100 Continue` (sent lazily when the handler reads the body) and `res.sendInformational()` for 103 Early Hints etc. Client side: default skip mode, `interim: "collect"` to capture 1xx into `res.informational[]`, `conn.requestStream()` async generator that yields each interim response and the final one as they arrive, and `fetchStream()` / `f.stream()` to expose the same streaming behaviour through the fetch API with proper connection pool management.
- **`packages/http` — WebSocket support**: implemented by Claude Code. `upgradeWebSocket(req, res)` for server-side handshake; `connectWebSocket(dialer, url, options?)` to open a new WebSocket connection, or `connectWebSocket(res, key)` to promote an existing `fetch()`/`fetchStream()` 101 response — both return a `WebSocketConnection` async iterable. Frame codec (read/write), masking, fragmented-message reassembly, ping/pong, and the close handshake are all implemented from scratch using the Web Crypto API (`crypto.subtle.digest` for SHA-1, `crypto.getRandomValues` for mask keys) with no external dependencies. Two fixed bugs in `fetch.ts` were required for pool safety: a case-insensitive `Connection: upgrade` check and immediate pool ejection on 101 to prevent a microtask race before hijack. Exported as three tree-shakeable entry points: `@webnet/http/websocket` (combined), `@webnet/http/websocket/client`, and `@webnet/http/websocket/server`.
- **`packages/http` — lint fix and WebSocket test coverage**: Claude Code fixed a `prefer-const` lint error in `ServerConnection` by refactoring `HijackFn` to accept `res` as a parameter (eliminating a forward-reference `let res!` pattern), updated the ESLint config to recognise `_`-prefixed variables as intentionally unused (`varsIgnorePattern`), and added test coverage for extended-length WebSocket frames (2-byte and 8-byte), multi-chunk `ReadBuffer` slicing, empty close frames, unsolicited PONG frames, invalid port URLs, and the `fetchStream()` 101 early-break path.
+1 -1
View File
@@ -9,7 +9,7 @@ export default tseslint.config(
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
},
},
+2 -3
View File
@@ -217,8 +217,7 @@ export class ServerConnection {
}
first = false
let res!: ServerResponseImpl
const hijackFn = async (options?: ServerHijackOptions) => {
const hijackFn = async (res: ServerResponseImpl, options?: ServerHijackOptions) => {
this.#hijacked = true
if (options?.sendResponse === false) {
await this.#writeBuffer.flushAll()
@@ -238,7 +237,7 @@ export class ServerConnection {
this.#writeBuffer.write(line)
await this.#writeBuffer.flushAll()
}
res = new ServerResponseImpl(req, hijackFn, sendInfo)
const res = new ServerResponseImpl(req, hijackFn, sendInfo)
try {
await handler({ req, res, transport: this.#transport })
+2 -2
View File
@@ -5,7 +5,7 @@ import { statusCodes } from "../common/spec.js"
import type { RawTransport, Reader } from "../common/types.js"
import type { ServerHijackOptions, ServerRequest, ServerResponse } from "./types.js"
type HijackFn = (options?: ServerHijackOptions) => Promise<RawTransport>
type HijackFn = (res: ServerResponseImpl, options?: ServerHijackOptions) => Promise<RawTransport>
type SendInformationalFn = (
status: number,
statusText: string,
@@ -118,7 +118,7 @@ export class ServerResponseImpl extends WritableHttpImpl implements ServerRespon
const fn = this.#hijackFn
this.#hijackFn = null
this.#hijacked = true
return fn(options)
return fn(this, options)
}
async sendInformational(
@@ -193,6 +193,14 @@ suite("WebSocket", { skip: skipIfNotIntegration }, () => {
})
suite("connectWebSocket(dialer, url) — URL validation", () => {
test("rejects invalid port in URL", async () => {
const [, dialer] = loopbackListener()
await assert.rejects(
() => connectWebSocket(dialer, "ws://localhost:0/"),
/Invalid port in WebSocket URL/,
)
})
test("rejects unsupported protocol", async () => {
const [, dialer] = loopbackListener()
await assert.rejects(
@@ -534,6 +542,50 @@ suite("WebSocket", { skip: skipIfNotIntegration }, () => {
)
})
test("sends and receives a 200-byte message (2-byte extended length)", async () => {
const payload = "x".repeat(200)
const received: string[] = []
await withWebSocketPair(
async (ws) => {
for await (const msg of ws) {
if (msg.type === "text") await ws.send(msg.data)
}
},
async (ws) => {
await ws.send(payload)
for await (const msg of ws) {
if (msg.type === "text") received.push(msg.data)
await ws.close()
}
},
)
assert.deepStrictEqual(received, [payload])
})
test("sends and receives a 70000-byte message (8-byte extended length)", async () => {
const payload = "y".repeat(70000)
const received: string[] = []
await withWebSocketPair(
async (ws) => {
for await (const msg of ws) {
if (msg.type === "text") await ws.send(msg.data)
}
},
async (ws) => {
await ws.send(payload)
for await (const msg of ws) {
if (msg.type === "text") received.push(msg.data)
await ws.close()
}
},
)
assert.deepStrictEqual(received, [payload])
})
test("ping from server triggers automatic pong; messages still flow", async () => {
const received: string[] = []
@@ -623,6 +675,41 @@ suite("WebSocket", { skip: skipIfNotIntegration }, () => {
)
})
test("close frame with no status code causes clean iterator exit", async () => {
const [listener, dialer] = loopbackListener()
let clientEnded = false
await Promise.all([
(async () => {
const t = await listener.accept()
listener.close()
const buf = new ReadBuffer(t)
let key = ""
let line: string
while ((line = await buf.readLine()) !== "") {
if (line.toLowerCase().startsWith("sec-websocket-key:"))
key = line.slice(line.indexOf(":") + 1).trim()
}
const accept = await computeAccept(key)
await t.write(
enc.encode(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`,
),
)
// Close frame with 0-byte payload (no status code) — exercises the echo fallback path
await t.write(new Uint8Array([0x88, 0x00]))
await t.close()
})(),
(async () => {
const ws = await connectWebSocket(dialer, "ws://localhost/")
for await (const _ of ws) { /* drain */ }
clientEnded = true
})(),
])
assert.ok(clientEnded)
})
test("abrupt transport close without Close frame exits iterator cleanly", async () => {
// Exercises the catch { break } path in the async iterator (common.ts readFrame error).
const [listener, dialer] = loopbackListener()
@@ -715,6 +802,86 @@ suite("WebSocket", { skip: skipIfNotIntegration }, () => {
})
})
suite("frame framing", () => {
test("client reads unmasked frame split across two transport writes (multi-chunk sliceBytes)", async () => {
const [listener, dialer] = loopbackListener()
const received: string[] = []
await Promise.all([
(async () => {
const t = await listener.accept()
listener.close()
const buf = new ReadBuffer(t)
let key = ""
let line: string
while ((line = await buf.readLine()) !== "") {
if (line.toLowerCase().startsWith("sec-websocket-key:"))
key = line.slice(line.indexOf(":") + 1).trim()
}
const accept = await computeAccept(key)
await t.write(
enc.encode(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`,
),
)
// Split frame: header + first 2 bytes of payload in one write, rest in another.
// sliceBytes(buf, 2, 5) will span both chunks → exercises the multi-chunk path.
const payload = enc.encode("hello")
await t.write(new Uint8Array([0x81, payload.length, payload[0], payload[1]]))
await t.write(payload.slice(2))
await t.write(new Uint8Array([0x88, 0x02, 0x03, 0xe8])) // close 1000
})(),
(async () => {
const ws = await connectWebSocket(dialer, "ws://localhost/")
for await (const msg of ws) {
if (msg.type === "text") received.push(msg.data)
}
})(),
])
assert.deepStrictEqual(received, ["hello"])
})
test("receives unsolicited PONG frame without disrupting message flow", async () => {
const [listener, dialer] = loopbackListener()
const received: string[] = []
await Promise.all([
(async () => {
const t = await listener.accept()
listener.close()
const buf = new ReadBuffer(t)
let key = ""
let line: string
while ((line = await buf.readLine()) !== "") {
if (line.toLowerCase().startsWith("sec-websocket-key:"))
key = line.slice(line.indexOf(":") + 1).trim()
}
const accept = await computeAccept(key)
await t.write(
enc.encode(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`,
),
)
// Unsolicited PONG, then a data message, then close
await t.write(new Uint8Array([0x8a, 0x00])) // PONG, no payload
const payload = enc.encode("hi")
await t.write(new Uint8Array([0x81, payload.length, ...payload]))
await t.write(new Uint8Array([0x88, 0x02, 0x03, 0xe8])) // close 1000
await t.close()
})(),
(async () => {
const ws = await connectWebSocket(dialer, "ws://localhost/")
for await (const msg of ws) {
if (msg.type === "text") received.push(msg.data)
}
})(),
])
assert.deepStrictEqual(received, ["hi"])
})
})
suite("send / ping errors after close", () => {
test("send() throws after close() is called", async () => {
await withWebSocketPair(
@@ -951,5 +1118,30 @@ suite("WebSocket", { skip: skipIfNotIntegration }, () => {
assert.strictEqual(received, "via-stream")
await serverTask
})
test("fetchStream() 101 response with early break (no hijack) rejects connection from pool", async () => {
// Exercises the done(true) path when finalStatus===101 but conn.hijacked===false.
const [listener, dialer] = loopbackListener()
const serverTask = (async () => {
const t = await listener.accept()
listener.close()
const sc = new ServerConnection(t)
await sc.handle(async ({ req, res }) => {
const ws = await upgradeWebSocket(req, res)
await ws.close()
for await (const _ of ws) { /* drain */ }
})
})()
const key = generateKey()
for await (const r of httpFetchStream(dialer, "http://localhost/", {
headers: { ...upgradeHeaders, "Sec-WebSocket-Key": key },
})) {
if (r.status === 101) break // intentionally skip connectWebSocket
}
await serverTask
})
})
})