Files
bun.sh/test/js/bun/http/bun-server.test.ts
robobun 2e8e7a000c Fix WebSocket to emit error event before close on handshake failure (#22325)
## Summary
This PR fixes WebSocket to correctly emit an `error` event before the
`close` event when the handshake fails (e.g., 302 redirects, non-101
status codes, missing headers).

Fixes #14338

## Problem
Previously, when a WebSocket connection failed during handshake (like
receiving a 302 redirect or connecting to a non-WebSocket server), Bun
would only emit a `close` event. This behavior differed from the WHATWG
WebSocket specification and other runtimes (browsers, Node.js with `ws`,
Deno) which emit both `error` and `close` events.

## Solution
Modified `WebSocket::didFailWithErrorCode()` in `WebSocket.cpp` to pass
`isConnectionError = true` for all handshake failure error codes,
ensuring an error event is dispatched before the close event when the
connection is in the CONNECTING state.

## Changes
- Updated error handling in `src/bun.js/bindings/webcore/WebSocket.cpp`
to emit error events for handshake failures
- Added comprehensive test coverage in
`test/regression/issue/14338.test.ts`

## Test Coverage
The test file includes:
1. **Negative test**: 302 redirect response - verifies error event is
emitted
2. **Negative test**: Non-WebSocket HTTP server - verifies error event
is emitted
3. **Positive test**: Successful WebSocket connection - verifies NO
error event is emitted

All tests pass with the fix applied.

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-09-02 03:26:51 -07:00

1298 lines
39 KiB
TypeScript

import type { Server, ServerWebSocket, Socket } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, rejectUnauthorizedScope, tempDirWithFiles } from "harness";
import path from "path";
describe("Server", () => {
test("normlizes incoming request URLs", async () => {
using server = Bun.serve({
fetch(request) {
return new Response(request.url, {
headers: {
"Connection": "close",
},
});
},
port: 0,
});
const received: string[] = [];
const expected: string[] = [];
for (let path of [
"/",
"/../",
"/./",
"/foo",
"/foo/",
"/foo/bar",
"/foo/bar/",
"/foo/bar/..",
"/foo/bar/../",
"/foo/bar/../?123",
"/foo/bar/../?123=456",
"/foo/bar/../#123=456",
"/",
"/../",
"/./",
"/foo",
"/foo/",
"/foo/bar",
"/foo/bar/",
"/foo/bar/..",
"/foo/bar/../",
"/foo/bar/../?123",
"/foo/bar/../?123=456",
"/foo/bar/../#123=456",
"/../".repeat(128),
"/./".repeat(128),
"/foo".repeat(128),
"/foo/".repeat(128),
"/foo/bar".repeat(128),
"/foo/bar/".repeat(128),
"/foo/bar/..".repeat(128),
"/foo/bar/../".repeat(128),
"/../".repeat(128),
"/./".repeat(128),
"/foo".repeat(128),
"/foo/".repeat(128),
"/foo/bar".repeat(128),
"/foo/bar/".repeat(128),
"/foo/bar/..".repeat(128),
"/foo/bar/../".repeat(128),
]) {
expected.push(new URL(path, "http://localhost:" + server.port).href);
const { promise, resolve } = Promise.withResolvers();
Bun.connect({
hostname: server.hostname,
port: server.port,
socket: {
async open(socket) {
socket.write(`GET ${path} HTTP/1.1\r\nHost: localhost:${server.port}\r\n\r\n`);
await socket.flush();
},
async data(socket, data) {
const lines = Buffer.from(data).toString("utf8");
received.push(lines.split("\r\n\r\n").at(-1)!);
await socket.end();
resolve();
},
},
});
await promise;
}
expect(received).toEqual(expected);
});
test("should not allow Bun.serve without first argument being a object", () => {
expect(() => {
//@ts-ignore
using server = Bun.serve();
}).toThrow("Bun.serve expects an object");
[undefined, null, 1, "string", true, false, Symbol("symbol")].forEach(value => {
expect(() => {
//@ts-ignore
using server = Bun.serve(value);
}).toThrow("Bun.serve expects an object");
});
});
test("should not allow Bun.serve with invalid tls option", () => {
[1, "string", true, Symbol("symbol")].forEach(value => {
expect(() => {
using server = Bun.serve({
//@ts-ignore
tls: value,
fetch() {
return new Response("Hello");
},
port: 0,
});
}).toThrow("tls option expects an object");
});
});
test("should allow Bun.serve using null or undefined tls option", () => {
[null, undefined].forEach(value => {
expect(() => {
using server = Bun.serve({
//@ts-ignore
tls: value,
fetch() {
return new Response("Hello");
},
port: 0,
});
}).not.toThrow("tls option expects an object");
});
});
test("returns active port when initializing server with 0 port", () => {
using server = Bun.serve({
fetch() {
return new Response("Hello");
},
port: 0,
});
expect(server.port).not.toBe(0);
expect(server.port).toBeDefined();
});
test("allows connecting to server", async () => {
using server = Bun.serve({
fetch() {
return new Response("Hello");
},
port: 0,
});
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response.text()).toBe("Hello");
});
test("allows listen on IPV6", async () => {
{
using server = Bun.serve({
hostname: "[::1]",
fetch() {
return new Response("Hello");
},
port: 0,
});
expect(server.port).not.toBe(0);
expect(server.port).toBeDefined();
}
{
using server = Bun.serve({
hostname: "::1",
fetch() {
return new Response("Hello");
},
port: 0,
});
expect(server.port).not.toBe(0);
expect(server.port).toBeDefined();
}
});
test("abort signal on server", async () => {
{
let signalOnServer = false;
let fetchAborted = false;
const abortController = new AbortController();
using server = Bun.serve({
async fetch(req) {
req.signal.addEventListener("abort", () => {
signalOnServer = true;
});
abortController.abort();
await Bun.sleep(15);
return new Response("Hello");
},
port: 0,
});
try {
await fetch(`http://${server.hostname}:${server.port}`, { signal: abortController.signal });
} catch (err: any) {
expect(err).toBeDefined();
expect(err?.name).toBe("AbortError");
fetchAborted = true;
}
// wait for the server to process the abort signal, fetch may throw before the server processes the signal
await Bun.sleep(15);
expect(signalOnServer).toBe(true);
expect(fetchAborted).toBe(true);
}
});
test("abort signal on server should only fire if aborted", async () => {
{
const abortController = new AbortController();
let signalOnServer = false;
let fetchAborted = false;
using server = Bun.serve({
async fetch(req) {
req.signal.addEventListener("abort", () => {
signalOnServer = true;
});
return new Response("Hello");
},
port: 0,
});
try {
await fetch(`http://${server.hostname}:${server.port}`, { signal: abortController.signal });
} catch {
fetchAborted = true;
}
// wait for the server to process the abort signal, fetch may throw before the server processes the signal
await Bun.sleep(15);
expect(signalOnServer).toBe(false);
expect(fetchAborted).toBe(false);
}
});
test("abort signal on server with direct stream", async () => {
{
let signalOnServer = false;
const abortController = new AbortController();
using server = Bun.serve({
async fetch(req) {
req.signal.addEventListener("abort", () => {
signalOnServer = true;
});
return new Response(
new ReadableStream({
type: "direct",
async pull(controller) {
abortController.abort();
const buffer = await Bun.file(import.meta.dir + "/fixture.html.gz").arrayBuffer();
controller.write(buffer);
//wait to detect the connection abortion
await Bun.sleep(15);
controller.close();
},
}),
{
headers: {
"Content-Encoding": "gzip",
"Content-Type": "text/html; charset=utf-8",
"Content-Length": "1",
},
},
);
},
port: 0,
});
try {
await fetch(`http://${server.hostname}:${server.port}`, { signal: abortController.signal });
} catch {}
await Bun.sleep(10);
expect(signalOnServer).toBe(true);
}
});
test("server.fetch should work with a string", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response("Hello World!");
},
});
{
const url = `http://${server.hostname}:${server.port}/`;
const response = await server.fetch(url);
expect(await response.text()).toBe("Hello World!");
expect(response.status).toBe(200);
expect(response.url).toBe(url);
}
});
test("server.fetch should work with a Request object", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response("Hello World!");
},
});
{
const url = `http://${server.hostname}:${server.port}/`;
const response = await server.fetch(new Request(url));
expect(await response.text()).toBe("Hello World!");
expect(response.status).toBe(200);
expect(response.url).toBe(url);
}
});
test("server should return a body for a OPTIONS Request", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response("Hello World!");
},
});
{
const url = `http://${server.hostname}:${server.port}/`;
const response = await fetch(
new Request(url, {
method: "OPTIONS",
}),
);
expect(await response.text()).toBe("Hello World!");
expect(response.status).toBe(200);
expect(response.url).toBe(url);
}
});
test("abort signal on server with stream", async () => {
{
let signalOnServer = false;
const abortController = new AbortController();
using server = Bun.serve({
async fetch(req) {
req.signal.addEventListener("abort", () => {
signalOnServer = true;
});
return new Response(
new ReadableStream({
async pull(controller) {
abortController.abort();
const buffer = await Bun.file(import.meta.dir + "/fixture.html.gz").arrayBuffer();
controller.enqueue(buffer);
//wait to detect the connection abortion
await Bun.sleep(15);
controller.close();
},
}),
{
headers: {
"Content-Encoding": "gzip",
"Content-Type": "text/html; charset=utf-8",
"Content-Length": "1",
},
},
);
},
port: 0,
});
try {
await fetch(`http://${server.hostname}:${server.port}`, { signal: abortController.signal });
} catch {}
await Bun.sleep(10);
expect(signalOnServer).toBe(true);
}
});
test("should not crash with big formData", async () => {
const proc = Bun.spawn({
cmd: [bunExe(), "big-form-data.fixture.js"],
cwd: import.meta.dir,
env: bunEnv,
});
await proc.exited;
expect(proc.exitCode).toBe(0);
});
test("should be able to parse source map and fetch small stream", async () => {
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), path.join("js-sink-sourmap-fixture", "index.mjs")],
cwd: import.meta.dir,
env: bunEnv,
stdin: "inherit",
stderr: "inherit",
stdout: "inherit",
});
expect(exitCode).toBe(0);
});
test("handshake failures should not impact future connections", async () => {
using server = Bun.serve({
tls: {
cert: "-----BEGIN CERTIFICATE-----\nMIIDrzCCApegAwIBAgIUHaenuNcUAu0tjDZGpc7fK4EX78gwDQYJKoZIhvcNAQEL\nBQAwaTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1TYW4gRnJh\nbmNpc2NvMQ0wCwYDVQQKDARPdmVuMREwDwYDVQQLDAhUZWFtIEJ1bjETMBEGA1UE\nAwwKc2VydmVyLWJ1bjAeFw0yMzA5MDYyMzI3MzRaFw0yNTA5MDUyMzI3MzRaMGkx\nCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNj\nbzENMAsGA1UECgwET3ZlbjERMA8GA1UECwwIVGVhbSBCdW4xEzARBgNVBAMMCnNl\ncnZlci1idW4wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+7odzr3yI\nYewRNRGIubF5hzT7Bym2dDab4yhaKf5drL+rcA0J15BM8QJ9iSmL1ovg7x35Q2MB\nKw3rl/Yyy3aJS8whZTUze522El72iZbdNbS+oH6GxB2gcZB6hmUehPjHIUH4icwP\ndwVUeR6fB7vkfDddLXe0Tb4qsO1EK8H0mr5PiQSXfj39Yc1QHY7/gZ/xeSrt/6yn\n0oH9HbjF2XLSL2j6cQPKEayartHN0SwzwLi0eWSzcziVPSQV7c6Lg9UuIHbKlgOF\nzDpcp1p1lRqv2yrT25im/dS6oy9XX+p7EfZxqeqpXX2fr5WKxgnzxI3sW93PG8FU\nIDHtnUsoHX3RAgMBAAGjTzBNMCwGA1UdEQQlMCOCCWxvY2FsaG9zdIcEfwAAAYcQ\nAAAAAAAAAAAAAAAAAAAAATAdBgNVHQ4EFgQUF3y/su4J/8ScpK+rM2LwTct6EQow\nDQYJKoZIhvcNAQELBQADggEBAGWGWp59Bmrk3Gt0bidFLEbvlOgGPWCT9ZrJUjgc\nhY44E+/t4gIBdoKOSwxo1tjtz7WsC2IYReLTXh1vTsgEitk0Bf4y7P40+pBwwZwK\naeIF9+PC6ZoAkXGFRoyEalaPVQDBg/DPOMRG9OH0lKfen9OGkZxmmjRLJzbyfAhU\noI/hExIjV8vehcvaJXmkfybJDYOYkN4BCNqPQHNf87ZNdFCb9Zgxwp/Ou+47J5k4\n5plQ+K7trfKXG3ABMbOJXNt1b0sH8jnpAsyHY4DLEQqxKYADbXsr3YX/yy6c0eOo\nX2bHGD1+zGsb7lGyNyoZrCZ0233glrEM4UxmvldBcWwOWfk=\n-----END CERTIFICATE-----\n",
key: "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+7odzr3yIYewR\nNRGIubF5hzT7Bym2dDab4yhaKf5drL+rcA0J15BM8QJ9iSmL1ovg7x35Q2MBKw3r\nl/Yyy3aJS8whZTUze522El72iZbdNbS+oH6GxB2gcZB6hmUehPjHIUH4icwPdwVU\neR6fB7vkfDddLXe0Tb4qsO1EK8H0mr5PiQSXfj39Yc1QHY7/gZ/xeSrt/6yn0oH9\nHbjF2XLSL2j6cQPKEayartHN0SwzwLi0eWSzcziVPSQV7c6Lg9UuIHbKlgOFzDpc\np1p1lRqv2yrT25im/dS6oy9XX+p7EfZxqeqpXX2fr5WKxgnzxI3sW93PG8FUIDHt\nnUsoHX3RAgMBAAECggEAAckMqkn+ER3c7YMsKRLc5bUE9ELe+ftUwfA6G+oXVorn\nE+uWCXGdNqI+TOZkQpurQBWn9IzTwv19QY+H740cxo0ozZVSPE4v4czIilv9XlVw\n3YCNa2uMxeqp76WMbz1xEhaFEgn6ASTVf3hxYJYKM0ljhPX8Vb8wWwlLONxr4w4X\nOnQAB5QE7i7LVRsQIpWKnGsALePeQjzhzUZDhz0UnTyGU6GfC+V+hN3RkC34A8oK\njR3/Wsjahev0Rpb+9Pbu3SgTrZTtQ+srlRrEsDG0wVqxkIk9ueSMOHlEtQ7zYZsk\nlX59Bb8LHNGQD5o+H1EDaC6OCsgzUAAJtDRZsPiZEQKBgQDs+YtVsc9RDMoC0x2y\nlVnP6IUDXt+2UXndZfJI3YS+wsfxiEkgK7G3AhjgB+C+DKEJzptVxP+212hHnXgr\n1gfW/x4g7OWBu4IxFmZ2J/Ojor+prhHJdCvD0VqnMzauzqLTe92aexiexXQGm+WW\nwRl3YZLmkft3rzs3ZPhc1G2X9QKBgQDOQq3rrxcvxSYaDZAb+6B/H7ZE4natMCiz\nLx/cWT8n+/CrJI2v3kDfdPl9yyXIOGrsqFgR3uhiUJnz+oeZFFHfYpslb8KvimHx\nKI+qcVDcprmYyXj2Lrf3fvj4pKorc+8TgOBDUpXIFhFDyM+0DmHLfq+7UqvjU9Hs\nkjER7baQ7QKBgQDTh508jU/FxWi9RL4Jnw9gaunwrEt9bxUc79dp+3J25V+c1k6Q\nDPDBr3mM4PtYKeXF30sBMKwiBf3rj0CpwI+W9ntqYIwtVbdNIfWsGtV8h9YWHG98\nJ9q5HLOS9EAnogPuS27walj7wL1k+NvjydJ1of+DGWQi3aQ6OkMIegap0QKBgBlR\nzCHLa5A8plG6an9U4z3Xubs5BZJ6//QHC+Uzu3IAFmob4Zy+Lr5/kITlpCyw6EdG\n3xDKiUJQXKW7kluzR92hMCRnVMHRvfYpoYEtydxcRxo/WS73SzQBjTSQmicdYzLE\ntkLtZ1+ZfeMRSpXy0gR198KKAnm0d2eQBqAJy0h9AoGBAM80zkd+LehBKq87Zoh7\ndtREVWslRD1C5HvFcAxYxBybcKzVpL89jIRGKB8SoZkF7edzhqvVzAMP0FFsEgCh\naClYGtO+uo+B91+5v2CCqowRJUGfbFOtCuSPR7+B3LDK8pkjK2SQ0mFPUfRA5z0z\nNVWtC0EYNBTRkqhYtqr3ZpUc\n-----END PRIVATE KEY-----\n",
},
fetch() {
return new Response("Hello");
},
port: 0,
});
const url = `${server.hostname}:${server.port}`;
try {
// This should fail because it's "http://" and not "https://"
await fetch(`http://${url}`, { tls: { rejectUnauthorized: false } });
expect.unreachable();
} catch (err: any) {
expect(err.code).toBe("ECONNRESET");
}
{
const result = await fetch(server.url, { tls: { rejectUnauthorized: false } }).then(res => res.text());
expect(result).toBe("Hello");
}
// Test that HTTPS keep-alive doesn't cause it to re-use the connection on
// the next attempt, when the next attempt has reject unauthorized enabled
{
expect(
async () => await fetch(server.url, { tls: { rejectUnauthorized: true } }).then(res => res.text()),
).toThrow("self signed certificate");
}
{
using _ = rejectUnauthorizedScope(true);
expect(async () => await fetch(server.url).then(res => res.text())).toThrow("self signed certificate");
}
{
using _ = rejectUnauthorizedScope(false);
const result = await fetch(server.url).then(res => res.text());
expect(result).toBe("Hello");
}
});
test("rejected promise handled by error method should not be logged", async () => {
const { stderr, exitCode } = Bun.spawnSync({
cmd: [bunExe(), path.join("rejected-promise-fixture.js")],
cwd: import.meta.dir,
env: bunEnv,
stderr: "pipe",
});
expect(stderr.toString("utf-8")).toBeEmpty();
expect(exitCode).toBe(0);
});
});
// By not timing out, this test passes.
test("Bun.serve().unref() works", async () => {
expect([path.join(import.meta.dir, "unref-fixture.ts")]).toRun();
});
test("unref keeps process alive for ongoing connections", async () => {
expect([path.join(import.meta.dir, "unref-fixture-2.ts")]).toRun();
});
test("Bun does not crash when given invalid config", async () => {
await using server1 = Bun.serve({
fetch(request, server) {
//
throw new Error("Should not be called");
},
port: 0,
});
const cases = [
{
fetch() {},
port: server1.port,
websocket: {},
},
{
port: server1.port,
get websocket() {
throw new Error();
},
},
{
fetch() {},
port: server1.port,
get websocket() {
throw new Error();
},
},
{
fetch() {},
port: server1.port,
get tls() {
throw new Error();
},
},
];
for (const options of cases) {
expect(() => {
Bun.serve(options as any);
}).toThrow();
}
});
test("Bun should be able to handle utf16 inside Content-Type header #11316", async () => {
using server = Bun.serve({
port: 0,
fetch() {
const fileSuffix = "测试.html".match(/\.([a-z0-9]*)$/i)?.[1];
expect(fileSuffix).toBeUTF16String();
return new Response("Hello World!\n", {
headers: {
"Content-Type": `text/${fileSuffix}`,
},
});
},
});
const result = await fetch(server.url);
expect(result.status).toBe(200);
expect(result.headers.get("Content-Type")).toBe("text/html");
});
test("should be able to await server.stop()", async () => {
const { promise, resolve } = Promise.withResolvers();
const ready = Promise.withResolvers();
const received = Promise.withResolvers();
using server = Bun.serve({
port: 0,
// Avoid waiting for DNS resolution in fetch()
hostname: "127.0.0.1",
async fetch(req) {
received.resolve();
await ready.promise;
return new Response("Hello World", {
headers: {
// Prevent Keep-Alive from keeping the connection open
"Connection": "close",
},
});
},
});
// Start the request
const responsePromise = fetch(server.url);
// Wait for the server to receive it.
await received.promise;
// Stop listening for new connections
const stopped = server.stop();
// Continue the request
ready.resolve();
// Wait for the response
await (await responsePromise).text();
// Wait for the server to stop
await stopped;
// Ensure the server is completely stopped
expect(async () => await fetch(server.url)).toThrow();
});
test("should be able to await server.stop(true) with keep alive", async () => {
const { promise, resolve } = Promise.withResolvers();
const ready = Promise.withResolvers();
const received = Promise.withResolvers();
using server = Bun.serve({
port: 0,
// Avoid waiting for DNS resolution in fetch()
hostname: "127.0.0.1",
async fetch(req) {
received.resolve();
await ready.promise;
return new Response("Hello World");
},
});
// Start the request
const responsePromise = fetch(server.url);
// Wait for the server to receive it.
await received.promise;
// Stop listening for new connections
const stopped = server.stop(true);
// Continue the request
ready.resolve();
// Wait for the server to stop
await stopped;
// It should fail before the server responds
expect(async () => {
await (await responsePromise).text();
}).toThrow();
// Ensure the server is completely stopped
expect(async () => await fetch(server.url)).toThrow();
});
test("should be able to async upgrade using custom protocol", async () => {
const { promise, resolve } = Promise.withResolvers<{ code: number; reason: string } | boolean>();
using server = Bun.serve<unknown>({
port: 0,
async fetch(req: Request, server: Server) {
await Bun.sleep(1);
if (server.upgrade(req)) return;
},
websocket: {
close(ws: ServerWebSocket<unknown>, code: number, reason: string): void | Promise<void> {
resolve({ code, reason });
},
message(ws: ServerWebSocket<unknown>, data: string): void | Promise<void> {
ws.send("world");
},
},
});
const ws = new WebSocket(server.url.href, "ocpp1.6");
ws.onopen = () => {
ws.send("hello");
};
ws.onmessage = e => {
console.log(e.data);
resolve(true);
};
expect(await promise).toBe(true);
});
test("should be able to abrubtly close a upload request", async () => {
const { promise, resolve } = Promise.withResolvers();
const { promise: promise2, resolve: resolve2 } = Promise.withResolvers();
using server = Bun.serve({
port: 0,
hostname: "localhost",
maxRequestBodySize: 1024 * 1024 * 1024 * 16,
async fetch(req) {
let total_size = 0;
req.signal.addEventListener("abort", resolve);
try {
for await (const chunk of req.body as ReadableStream) {
total_size += chunk.length;
if (total_size > 1024 * 1024 * 1024) {
return new Response("too big", { status: 413 });
}
}
} catch (e) {
expect((e as Error)?.name).toBe("AbortError");
} finally {
resolve2();
}
return new Response("Received " + total_size);
},
});
// ~100KB
const chunk = Buffer.alloc(1024 * 100, "a");
// ~1GB
const MAX_PAYLOAD = 1024 * 1024 * 1024;
const request = Buffer.from(
`POST / HTTP/1.1\r\nHost: ${server.hostname}:${server.port}\r\nContent-Length: ${MAX_PAYLOAD}\r\n\r\n`,
);
type SocketInfo = { state: number; pending: Buffer | null };
function tryWritePending(socket: Socket<SocketInfo>) {
if (socket.data.pending === null) {
// first write
socket.data.pending = request;
}
const data = socket.data.pending as Buffer;
const written = socket.write(data);
if (written < data.byteLength) {
// partial write
socket.data.pending = data.slice(0, written);
return false;
}
// full write got to next state
if (socket.data.state === 0) {
// request sent -> send chunk
socket.data.pending = chunk;
} else {
// chunk sent -> delay shutdown
setTimeout(() => socket.shutdown(), 100);
}
socket.data.state++;
socket.flush();
return true;
}
function trySend(socket: Socket<SocketInfo>) {
while (socket.data.state < 2) {
if (!tryWritePending(socket)) {
return;
}
}
return;
}
await Bun.connect({
hostname: server.hostname,
port: server.port,
data: {
state: 0,
pending: null,
} as SocketInfo,
socket: {
open: trySend,
drain: trySend,
data(socket, data) {},
},
});
await Promise.all([promise, promise2]);
expect().pass();
});
// This test is disabled because it can OOM the CI
test.skip("should be able to stream huge amounts of data", async () => {
const buf = Buffer.alloc(1024 * 1024 * 256);
const CONTENT_LENGTH = 3 * 1024 * 1024 * 1024;
let received = 0;
let written = 0;
using server = Bun.serve({
port: 0,
fetch() {
return new Response(
new ReadableStream({
type: "direct",
async pull(controller) {
while (written < CONTENT_LENGTH) {
written += buf.byteLength;
await controller.write(buf);
}
controller.close();
},
}),
{
headers: {
"Content-Type": "text/plain",
"Content-Length": CONTENT_LENGTH.toString(),
},
},
);
},
});
const response = await fetch(server.url);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("text/plain");
const reader = (response.body as ReadableStream).getReader();
while (true) {
const { done, value } = await reader.read();
received += value ? value.byteLength : 0;
if (done) {
break;
}
}
expect(written).toBe(CONTENT_LENGTH);
expect(received).toBe(CONTENT_LENGTH);
}, 30_000);
describe("HEAD requests #15355", () => {
test("should be able to make HEAD requests with content-length or transfer-encoding (async)", async () => {
using server = Bun.serve({
port: 0,
async fetch(req) {
await Bun.sleep(1);
if (req.method === "HEAD") {
if (req.url.endsWith("/content-length")) {
return new Response(null, {
headers: {
"Content-Length": "11",
},
});
}
return new Response(null, {
headers: {
"Transfer-Encoding": "chunked",
},
});
}
if (req.url.endsWith("/content-length")) {
return new Response("Hello World");
}
return new Response(async function* () {
yield "Hello";
await Bun.sleep(1);
yield " ";
await Bun.sleep(1);
yield "World";
});
},
});
{
const response = await fetch(server.url + "/content-length");
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("Hello World");
}
{
const response = await fetch(server.url + "/chunked");
expect(response.status).toBe(200);
expect(response.headers.get("transfer-encoding")).toBe("chunked");
expect(await response.text()).toBe("Hello World");
}
{
const response = await fetch(server.url + "/content-length", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("");
}
{
const response = await fetch(server.url + "/chunked", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("transfer-encoding")).toBe("chunked");
expect(await response.text()).toBe("");
}
});
test("should be able to make HEAD requests with content-length or transfer-encoding (sync)", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
if (req.method === "HEAD") {
if (req.url.endsWith("/content-length")) {
return new Response(null, {
headers: {
"Content-Length": "11",
},
});
}
return new Response(null, {
headers: {
"Transfer-Encoding": "chunked",
},
});
}
if (req.url.endsWith("/content-length")) {
return new Response("Hello World");
}
return new Response(async function* () {
yield "Hello";
await Bun.sleep(1);
yield " ";
await Bun.sleep(1);
yield "World";
});
},
});
{
const response = await fetch(server.url + "/content-length");
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("Hello World");
}
{
const response = await fetch(server.url + "/chunked");
expect(response.status).toBe(200);
expect(response.headers.get("transfer-encoding")).toBe("chunked");
expect(await response.text()).toBe("Hello World");
}
{
const response = await fetch(server.url + "/content-length", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("");
}
{
const response = await fetch(server.url + "/chunked", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("transfer-encoding")).toBe("chunked");
expect(await response.text()).toBe("");
}
});
test("should fallback to the body if content-length is missing in the headers", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
if (req.url.endsWith("/content-length")) {
return new Response("Hello World", {
headers: {
"Content-Type": "text/plain",
"X-Bun-Test": "1",
},
});
}
if (req.url.endsWith("/chunked")) {
return new Response(
async function* () {
yield "Hello";
await Bun.sleep(1);
yield " ";
await Bun.sleep(1);
yield "World";
},
{
headers: {
"Content-Type": "text/plain",
"X-Bun-Test": "1",
},
},
);
}
return new Response(null, {
headers: {
"Content-Type": "text/plain",
"X-Bun-Test": "1",
},
});
},
});
{
const response = await fetch(server.url + "/content-length", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(response.headers.get("x-bun-test")).toBe("1");
expect(await response.text()).toBe("");
}
{
const response = await fetch(server.url + "/chunked", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("transfer-encoding")).toBe("chunked");
expect(response.headers.get("x-bun-test")).toBe("1");
expect(await response.text()).toBe("");
}
{
const response = await fetch(server.url + "/null", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("0");
expect(response.headers.get("x-bun-test")).toBe("1");
expect(await response.text()).toBe("");
}
});
test("HEAD requests should not have body", async () => {
const dir = tempDirWithFiles("fsr", {
"hello": "Hello World",
});
const filename = path.join(dir, "hello");
using server = Bun.serve({
port: 0,
fetch(req) {
if (req.url.endsWith("/file")) {
return new Response(Bun.file(filename));
}
return new Response("Hello World");
},
});
{
const response = await fetch(server.url);
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("Hello World");
}
{
const response = await fetch(server.url + "/file");
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("Hello World");
}
function doHead(server: Server, path: string): Promise<{ headers: string; body: string }> {
const { promise, resolve } = Promise.withResolvers();
// use node net to make a HEAD request
const net = require("net");
const url = new URL(server.url);
const socket = net.createConnection(url.port, url.hostname);
socket.write(`HEAD ${path} HTTP/1.1\r\nHost: ${url.hostname}:${url.port}\r\n\r\n`);
let body = "";
let headers = "";
socket.on("data", data => {
body += data.toString();
if (!headers) {
const headerIndex = body.indexOf("\r\n\r\n");
if (headerIndex !== -1) {
headers = body.slice(0, headerIndex);
body = body.slice(headerIndex + 4);
setTimeout(() => {
// wait to see if we get extra data
resolve({ headers, body });
socket.destroy();
}, 100);
}
}
});
return promise as Promise<{ headers: string; body: string }>;
}
{
const response = await fetch(server.url, {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("");
}
{
const response = await fetch(server.url + "/file", {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("");
}
{
const { headers, body } = await doHead(server, "/");
expect(headers.toLowerCase()).toContain("content-length: 11");
expect(body).toBe("");
}
{
const { headers, body } = await doHead(server, "/file");
expect(headers.toLowerCase()).toContain("content-length: 11");
expect(body).toBe("");
}
});
describe("HEAD request should respect status", () => {
test("status only without headers", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response(null, { status: 404 });
},
});
const response = await fetch(server.url, { method: "HEAD" });
expect(response.status).toBe(404);
expect(response.headers.get("content-length")).toBe("0");
});
test("status only with headers", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response(null, {
status: 404,
headers: { "X-Bun-Test": "1", "Content-Length": "11" },
});
},
});
const response = await fetch(server.url, { method: "HEAD" });
expect(response.status).toBe(404);
expect(response.headers.get("content-length")).toBe("11");
expect(response.headers.get("x-bun-test")).toBe("1");
});
test("status only with transfer-encoding", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response(null, { status: 404, headers: { "Transfer-Encoding": "chunked" } });
},
});
const response = await fetch(server.url, { method: "HEAD" });
expect(response.status).toBe(404);
expect(response.headers.get("transfer-encoding")).toBe("chunked");
});
test("status only with body", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response("Hello World", { status: 404 });
},
});
const response = await fetch(server.url, { method: "HEAD" });
expect(response.status).toBe(404);
expect(response.headers.get("content-length")).toBe("11");
expect(await response.text()).toBe("");
});
test("should allow Strict-Transport-Security", async () => {
using server = Bun.serve({
port: 0,
fetch(req) {
return new Response("Hello World", {
status: 200,
headers: { "Strict-Transport-Security": "max-age=31536000" },
});
},
});
const response = await fetch(server.url, { method: "HEAD" });
expect(response.status).toBe(200);
expect(response.headers.get("strict-transport-security")).toBe("max-age=31536000");
});
});
});
describe("websocket and routes test", () => {
const serverConfigurations = [
{
// main route for upgrade
routes: {
"/": (req: Request, server: Server) => {
if (server.upgrade(req)) return;
return new Response("Forbidden", { status: 403 });
},
},
shouldBeUpgraded: true,
hasPOST: false,
testName: "main route for upgrade",
},
{
// Generic route for upgrade
routes: {
"/*": (req: Request, server: Server) => {
if (server.upgrade(req)) return;
return new Response("Forbidden", { status: 403 });
},
},
shouldBeUpgraded: true,
hasPOST: false,
expectedPath: "/bun",
testName: "generic route for upgrade",
},
// GET route for upgrade
{
routes: {
"/ws": {
GET: (req: Request, server: Server) => {
if (server.upgrade(req)) return;
return new Response("Forbidden", { status: 403 });
},
POST: (req: Request) => {
return new Response(req.body);
},
},
},
shouldBeUpgraded: true,
hasPOST: true,
expectedPath: "/ws",
testName: "GET route for upgrade",
},
// POST route and fetch route for upgrade
{
routes: {
"/": {
POST: (req: Request, server: Server) => {
return new Response("Hello World");
},
},
},
fetch: (req: Request, server: Server) => {
if (server.upgrade(req)) return;
return new Response("Forbidden", { status: 403 });
},
shouldBeUpgraded: true,
hasPOST: true,
testName: "POST route + fetch route for upgrade",
},
// POST route for upgrade
{
routes: {
"/": {
POST: (req: Request, server: Server) => {
return new Response("Hello World");
},
},
},
shouldBeUpgraded: false,
hasPOST: true,
testName: "POST route for upgrade and no fetch",
},
// fetch only
{
fetch: (req: Request, server: Server) => {
if (server.upgrade(req)) return;
return new Response("Forbidden", { status: 403 });
},
shouldBeUpgraded: true,
hasPOST: false,
testName: "fetch only for upgrade",
},
];
for (const config of serverConfigurations) {
const { routes, fetch: serverFetch, shouldBeUpgraded, hasPOST, expectedPath, testName } = config;
test(testName, async () => {
using server = Bun.serve({
port: 0,
routes,
fetch: serverFetch,
websocket: {
message: (ws, message) => {
// PING PONG
ws.send(`recv: ${message}`);
},
},
});
{
const { promise, resolve, reject } = Promise.withResolvers();
const url = new URL(server.url);
url.pathname = expectedPath || "/";
url.hostname = "127.0.0.1";
const ws = new WebSocket(url.toString()); // bun crashes here
ws.onopen = () => {
ws.send("Hello server");
};
ws.onmessage = event => {
resolve(event.data);
ws.close();
};
let errorFired = false;
ws.onerror = e => {
errorFired = true;
// Don't reject on error, we expect both error and close for failed upgrade
};
ws.onclose = event => {
if (!shouldBeUpgraded) {
// For failed upgrade, resolve with the close code
resolve(event.code);
} else {
reject(event.code);
}
};
if (shouldBeUpgraded) {
const result = await promise;
expect(result).toBe("recv: Hello server");
} else {
const result = await promise;
expect(errorFired).toBe(true); // Error event should fire for failed upgrade
expect(result).toBe(1002);
}
if (hasPOST) {
const result = await fetch(url, {
method: "POST",
body: "Hello World",
});
expect(result.status).toBe(200);
const body = await result.text();
expect(body).toBe("Hello World");
}
}
});
}
});
test("should be able to redirect when using empty streams #15320", async () => {
using server = Bun.serve({
port: 0,
websocket: void 0,
async fetch(req, server2) {
const url = new URL(req.url);
if (url.pathname === "/redirect") {
const emptyStream = new ReadableStream({
start(controller) {
// Immediately close the stream to make it empty
controller.close();
},
});
return new Response(emptyStream, {
status: 307,
headers: {
location: "/",
},
});
}
return new Response("Hello, World");
},
});
const response = await fetch(`http://localhost:${server.port}/redirect`);
expect(await response.text()).toBe("Hello, World");
});