mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 02:48:50 +00:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Claude Bot <claude-bot@bun.sh>
1304 lines
36 KiB
TypeScript
1304 lines
36 KiB
TypeScript
import type { Server, ServerWebSocket, Socket } from "bun";
|
|
import { describe, expect, test } from "bun:test";
|
|
import { bunEnv, bunExe, bunRun, rejectUnauthorizedScope, tempDirWithFiles, tls } from "harness";
|
|
import path from "path";
|
|
|
|
describe.concurrent("Server", () => {
|
|
test("should not use 100% CPU when websocket is idle", async () => {
|
|
const { stderr } = bunRun(path.join(import.meta.dir, "bun-websocket-cpu-fixture.js"));
|
|
expect(stderr).toBe("");
|
|
});
|
|
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("TLSOptions must be 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("TLSOptions must be 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 abortPromise = Promise.withResolvers();
|
|
let fetchAborted = false;
|
|
const abortController = new AbortController();
|
|
using server = Bun.serve({
|
|
async fetch(req) {
|
|
req.signal.addEventListener("abort", () => {
|
|
abortPromise.resolve();
|
|
});
|
|
abortController.abort();
|
|
await abortPromise.promise;
|
|
return new Response("Hello");
|
|
},
|
|
port: 0,
|
|
});
|
|
|
|
try {
|
|
await fetch(`http://${server.hostname}:${server.port}`, { signal: abortController.signal }).then(res =>
|
|
res.text(),
|
|
);
|
|
} 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 abortPromise.promise;
|
|
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 }).then(res =>
|
|
res.text(),
|
|
);
|
|
} 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 }).then(res =>
|
|
res.text(),
|
|
);
|
|
} 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 }).then(res =>
|
|
res.text(),
|
|
);
|
|
} 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,
|
|
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];
|
|
|
|
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");
|
|
});
|