Compare commits
2
Commits
4250c3d258
...
175a6a14d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
175a6a14d2 | ||
|
|
02912f25be |
@@ -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
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user