Files
bun.sh/test/bun.js/serve.test.ts
2023-01-15 03:21:34 -08:00

970 lines
27 KiB
TypeScript

import { file, gc, serve } from "bun";
import { afterEach, describe, it, expect, afterAll } from "bun:test";
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
afterEach(() => gc(true));
const count = 200;
let port = 10000;
let server;
async function runTest(serverOptions, test) {
if (server) {
server.reload(serverOptions);
} else {
while (!server) {
try {
serverOptions.port = port++;
server = serve(serverOptions);
break;
} catch (e: any) {
if (
e?.message !==
`Failed to start server. Is port ${serverOptions.port} in use?`
) {
throw e;
}
}
}
}
await test(server);
}
afterAll(() => {
if (server) {
server.stop(true);
server = undefined;
}
});
[ 100, 101, 418, 999 ].forEach((statusCode) => {
it(`should response with HTTP status code (${statusCode})`, async () => {
await runTest(
{
fetch() {
return new Response("Foo Bar", { status: statusCode });
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(response.status).toBe(statusCode);
expect(await response.text()).toBe("Foo Bar");
},
);
});
});
[ -200, 42, 12345, Math.PI ].forEach((statusCode) => {
it(`should ignore invalid HTTP status code (${statusCode})`, async () => {
await runTest(
{
fetch() {
return new Response("Foo Bar", { status: statusCode });
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(response.status).toBe(200);
expect(await response.text()).toBe("Foo Bar");
}
);
});
});
it("should display a welcome message when the response value type is incorrect", async () => {
await runTest(
{
fetch(req) {
return Symbol("invalid response type");
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
const text = await response.text();
expect(text).toContain("Welcome to Bun!");
},
);
});
it("should work for a file", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(file(fixture));
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("request.url should log successfully", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
var expected;
await runTest(
{
fetch(req) {
expect(Bun.inspect(req).includes(expected)).toBe(true);
return new Response(file(fixture));
},
},
async (server) => {
expected = `http://localhost:${server.port}/helloooo`;
const response = await fetch(expected);
expect(response.url).toBe(expected);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("request.url should be based on the Host header", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
expect(req.url).toBe("http://example.com/helloooo");
return new Response(file(fixture));
},
},
async (server) => {
const expected = `http://${server.hostname}:${server.port}/helloooo`;
const response = await fetch(expected, {
headers: {
Host: "example.com",
},
});
expect(response.url).toBe(expected);
expect(await response.text()).toBe(textToExpect);
},
);
});
describe("streaming", () => {
describe("error handler", () => {
it("throw on pull reports an error and close the connection", async () => {
var pass = false;
await runTest(
{
error(e) {
pass = true;
return new Response("PASS", { status: 555 });
},
fetch(req) {
return new Response(
new ReadableStream({
pull(controller) {
throw new Error("FAIL");
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
if (response.status > 0) {
expect(response.status).toBe(555);
expect(await response.text()).toBe("PASS");
}
expect(pass).toBe(true);
},
);
});
it("throw on pull after writing should not call the error handler", async () => {
var pass = true;
await runTest(
{
error(e) {
pass = false;
return new Response("FAIL", { status: 555 });
},
fetch(req) {
return new Response(
new ReadableStream({
async pull(controller) {
controller.enqueue("PASS");
controller.close();
throw new Error("error");
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
// connection terminated
expect(response.status).toBe(200);
expect(await response.text()).toBe("PASS");
expect(pass).toBe(true);
},
);
});
});
it("text from JS, one chunk", async () => {
const relative = new URL("./fetch.js.txt", import.meta.url);
const textToExpect = readFileSync(relative, "utf-8");
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(textToExpect);
controller.close();
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
const text = await response.text();
expect(text.length).toBe(textToExpect.length);
expect(text).toBe(textToExpect);
},
);
});
it("text from JS, two chunks", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(textToExpect.substring(0, 100));
controller.enqueue(textToExpect.substring(100));
controller.close();
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("Error handler is called when a throwing stream hasn't written anything", async () => {
await runTest(
{
error(e) {
return new Response("Test Passed", { status: 200 });
},
fetch(req) {
return new Response(
new ReadableStream({
start(controller) {
throw new Error("Test Passed");
},
}),
{
status: 404,
},
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("Test Passed");
},
);
});
// Also verifies error handler reset in `.reload()` due to test above
it("text from JS throws on start with no error handler", async () => {
await runTest(
{
error: undefined,
fetch(req) {
return new Response(
new ReadableStream({
start(controller) {
throw new Error("Test Passed");
},
}),
{
status: 420,
headers: {
"x-what": "123",
},
},
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(response.status).toBe(500);
},
);
});
it("text from JS throws on start has error handler", async () => {
var pass = false;
var err;
await runTest(
{
error(e) {
pass = true;
err = e;
return new Response("Fail", { status: 500 });
},
fetch(req) {
return new Response(
new ReadableStream({
start(controller) {
throw new TypeError("error");
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(response.status).toBe(500);
expect(await response.text()).toBe("Fail");
expect(pass).toBe(true);
expect(err?.name).toBe("TypeError");
expect(err?.message).toBe("error");
},
);
});
it("text from JS, 2 chunks, with delay", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(textToExpect.substring(0, 100));
queueMicrotask(() => {
controller.enqueue(textToExpect.substring(100));
controller.close();
});
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("text from JS, 1 chunk via pull()", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(textToExpect);
controller.close();
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
const text = await response.text();
expect(text).toBe(textToExpect);
},
);
});
it("text from JS, 2 chunks, with delay in pull", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(textToExpect.substring(0, 100));
queueMicrotask(() => {
controller.enqueue(textToExpect.substring(100));
controller.close();
});
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("text from JS, 3 chunks, 1 empty, with delay in pull", async () => {
const textToExpect = "hello world";
const groups = [
["hello", "", " world"],
["", "hello ", "world"],
["hello ", "world", ""],
["hello world", "", ""],
["", "", "hello world"],
];
var count = 0;
for (const chunks of groups) {
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
async pull(controller) {
for (let chunk of chunks) {
controller.enqueue(Buffer.from(chunk));
await 1;
}
await 1;
controller.close();
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
count++;
},
);
}
expect(count).toBe(groups.length);
});
it("text from JS, 2 chunks, with async pull", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
async pull(controller) {
controller.enqueue(textToExpect.substring(0, 100));
await Promise.resolve();
controller.enqueue(textToExpect.substring(100));
await Promise.resolve();
controller.close();
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("text from JS, 10 chunks, with async pull", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(
new ReadableStream({
async pull(controller) {
var remain = textToExpect;
for (let i = 0; i < 10 && remain.length > 0; i++) {
controller.enqueue(remain.substring(0, 100));
remain = remain.substring(100);
await new Promise((resolve) => queueMicrotask(resolve));
}
controller.enqueue(remain);
controller.close();
},
}),
);
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
},
);
});
});
it("should work for a hello world", async () => {
await runTest(
{
fetch(req) {
return new Response(`Hello, world!`);
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response.text()).toBe("Hello, world!");
},
);
});
it("should work for a blob", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(new Blob([textToExpect]));
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("should work for a blob stream", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(new Blob([textToExpect]).stream());
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("should work for a file stream", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
fetch(req) {
return new Response(file(fixture).stream());
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response.text()).toBe(textToExpect);
},
);
});
it("fetch should work with headers", async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
await runTest(
{
fetch(req) {
if (req.headers.get("X-Foo") !== "bar") {
return new Response("X-Foo header not set", { status: 500 });
}
return new Response(file(fixture), {
headers: { "X-Both-Ways": "1" },
});
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`, {
headers: {
"X-Foo": "bar",
},
});
expect(response.status).toBe(200);
expect(response.headers.get("X-Both-Ways")).toBe("1");
},
);
});
it(`should work for a file ${count} times serial`, async () => {
const fixture = resolve(import.meta.dir, "./fetch.js.txt");
const textToExpect = readFileSync(fixture, "utf-8");
await runTest(
{
async fetch(req) {
return new Response(file(fixture));
},
},
async (server) => {
for (let i = 0; i < count; i++) {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
}
},
);
});
it(`should work for ArrayBuffer ${count} times serial`, async () => {
const textToExpect = "hello";
await runTest(
{
fetch(req) {
return new Response(new TextEncoder().encode(textToExpect));
},
},
async (server) => {
for (let i = 0; i < count; i++) {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(await response.text()).toBe(textToExpect);
}
},
);
});
describe("parallel", () => {
it(`should work for text ${count} times in batches of 5`, async () => {
const textToExpect = "hello";
await runTest(
{
fetch(req) {
return new Response(textToExpect);
},
},
async (server) => {
for (let i = 0; i < count; ) {
let responses = await Promise.all([
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
]);
for (let response of responses) {
expect(await response.text()).toBe(textToExpect);
}
i += responses.length;
}
},
);
});
it(`should work for Uint8Array ${count} times in batches of 5`, async () => {
const textToExpect = "hello";
await runTest(
{
fetch(req) {
return new Response(new TextEncoder().encode(textToExpect));
},
},
async (server) => {
for (let i = 0; i < count; ) {
let responses = await Promise.all([
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
fetch(`http://${server.hostname}:${server.port}`),
]);
for (let response of responses) {
expect(await response.text()).toBe(textToExpect);
}
i += responses.length;
}
},
);
});
});
it("should support reloading", async () => {
const first = (req) => new Response("first");
const second = (req) => new Response("second");
await runTest(
{
fetch: first,
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response.text()).toBe("first");
server.reload({ fetch: second });
const response2 = await fetch(`http://${server.hostname}:${server.port}`);
expect(await response2.text()).toBe("second");
},
);
});
describe("status code text", () => {
const fixture = {
200: "OK",
201: "Created",
202: "Accepted",
203: "Non-Authoritative Information",
204: "No Content",
205: "Reset Content",
206: "Partial Content",
207: "Multi-Status",
208: "Already Reported",
226: "IM Used",
300: "Multiple Choices",
301: "Moved Permanently",
302: "Found",
303: "See Other",
304: "Not Modified",
305: "Use Proxy",
306: "Switch Proxy",
307: "Temporary Redirect",
308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
406: "Not Acceptable",
407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
411: "Length Required",
412: "Precondition Failed",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
416: "Range Not Satisfiable",
417: "Expectation Failed",
418: "I'm a Teapot",
421: "Misdirected Request",
422: "Unprocessable Entity",
423: "Locked",
424: "Failed Dependency",
425: "Too Early",
426: "Upgrade Required",
428: "Precondition Required",
429: "Too Many Requests",
431: "Request Header Fields Too Large",
451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
505: "HTTP Version Not Supported",
506: "Variant Also Negotiates",
507: "Insufficient Storage",
508: "Loop Detected",
510: "Not Extended",
511: "Network Authentication Required",
};
for (let code in fixture) {
it(`should return ${code} ${fixture[code]}`, async () => {
await runTest(
{
fetch(req) {
return new Response("hey", { status: +code });
},
},
async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}`,
);
expect(response.status).toBe(parseInt(code));
expect(response.statusText).toBe(fixture[code]);
},
);
});
}
});
it("should support multiple Set-Cookie headers", async () => {
await runTest(
{
fetch(req) {
return new Response("hello", {
headers: [
["Another-Header", "1"],
["Set-Cookie", "foo=bar"],
["Set-Cookie", "baz=qux"],
],
});
},
},
async (server) => {
const response = await fetch(`http://${server.hostname}:${server.port}`);
expect(response.headers.getAll("Set-Cookie")).toEqual([
"foo=bar",
"baz=qux",
]);
expect(response.headers.get("Set-Cookie")).toEqual("foo=bar, baz=qux");
const cloned = response.clone().headers;
expect(response.headers.getAll("Set-Cookie")).toEqual([
"foo=bar",
"baz=qux",
]);
response.headers.delete("Set-Cookie");
expect(response.headers.getAll("Set-Cookie")).toEqual([]);
response.headers.delete("Set-Cookie");
expect(cloned.getAll("Set-Cookie")).toEqual(["foo=bar", "baz=qux"]);
expect(new Headers(cloned).getAll("Set-Cookie")).toEqual([
"foo=bar",
"baz=qux",
]);
},
);
});
describe("should support Content-Range with Bun.file()", () => {
// this must be a big file so we can test potentially multiple chunks
// more than 65 KB
const full = (function () {
const fixture = resolve(import.meta.dir + "/fetch.js.txt");
const chunk = readFileSync(fixture);
var whole = new Uint8Array(chunk.byteLength * 128);
for (var i = 0; i < 128; i++) {
whole.set(chunk, i * chunk.byteLength);
}
writeFileSync(fixture + ".big", whole);
return whole;
})();
const fixture = resolve(import.meta.dir + "/fetch.js.txt") + ".big";
const getServer = runTest.bind(null, {
fetch(req) {
const { searchParams } = new URL(req.url);
const start = Number(searchParams.get("start"));
const end = Number(searchParams.get("end"));
return new Response(Bun.file(fixture).slice(start, end));
},
});
const good = [
[0, 1],
[1, 2],
[0, 10],
[10, 20],
[0, Infinity],
[10, Infinity],
[NaN, Infinity],
[full.byteLength - 10, full.byteLength],
[full.byteLength - 10, full.byteLength - 1],
[full.byteLength - 1, full.byteLength],
[0, full.byteLength],
] as const;
for (const [start, end] of good) {
it(`good range: ${start} - ${end}`, async () => {
await getServer(async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}/?start=${start}&end=${end}`,
{},
{ verbose: true },
);
expect(await response.arrayBuffer()).toEqual(
full.buffer.slice(start, end),
);
expect(response.status).toBe(
start > 0 || end < full.byteLength ? 206 : 200,
);
});
});
}
const emptyRanges = [
[0, 0],
[1, 1],
[10, 10],
[-Infinity, -Infinity],
[Infinity, Infinity],
[NaN, NaN],
[(full.byteLength / 2) | 0, (full.byteLength / 2) | 0],
[full.byteLength, full.byteLength],
[full.byteLength - 1, full.byteLength - 1],
];
for (const [start, end] of emptyRanges) {
it(`empty range: ${start} - ${end}`, async () => {
await getServer(async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}/?start=${start}&end=${end}`,
);
const out = await response.arrayBuffer();
expect(out).toEqual(new ArrayBuffer(0));
expect(response.status).toBe(206);
});
});
}
const badRanges = [
[10, NaN],
[10, -Infinity],
[-(full.byteLength / 2) | 0, Infinity],
[-(full.byteLength / 2) | 0, -Infinity],
[full.byteLength + 100, full.byteLength],
[full.byteLength + 100, full.byteLength + 100],
[full.byteLength + 100, full.byteLength + 1],
[full.byteLength + 100, -full.byteLength],
];
for (const [start, end] of badRanges) {
it(`bad range: ${start} - ${end}`, async () => {
await getServer(async (server) => {
const response = await fetch(
`http://${server.hostname}:${server.port}/?start=${start}&end=${end}`,
);
const out = await response.arrayBuffer();
expect(out).toEqual(new ArrayBuffer(0));
expect(response.status).toBe(206);
});
});
}
});