mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Add a new generator for JS → Zig bindings. The bulk of the conversion is done in C++, after which the data is transformed into an FFI-safe representation, passed to Zig, and then finally transformed into idiomatic Zig types. In its current form, the new bindings generator supports: * Signed and unsigned integers * Floats (plus a “finite” variant that disallows NaN and infinities) * Strings * ArrayBuffer (accepts ArrayBuffer, TypedArray, or DataView) * Blob * Optional types * Nullable types (allows null, whereas Optional only allows undefined) * Arrays * User-defined string enumerations * User-defined unions (fields can optionally be named to provide a better experience in Zig) * Null and undefined, for use in unions (can more efficiently represent optional/nullable unions than wrapping a union in an optional) * User-defined dictionaries (arbitrary key-value pairs; expects a JS object and parses it into a struct) * Default values for dictionary members * Alternative names for dictionary members (e.g., to support both `serverName` and `servername` without taking up twice the space) * Descriptive error messages * Automatic `fromJS` functions in Zig for dictionaries * Automatic `deinit` functions for the generated Zig types Although this bindings generator has many features not present in `bindgen.ts`, it does not yet implement all of `bindgen.ts`'s functionality, so for the time being, it has been named `bindgenv2`, and its configuration is specified in `.bindv2.ts` files. Once all `bindgen.ts`'s functionality has been incorporated, it will be renamed. This PR ports `SSLConfig` to use the new bindings generator; see `SSLConfig.bindv2.ts`. (For internal tracking: fixes STAB-1319, STAB-1322, STAB-1323, STAB-1324) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Alistair Smith <hi@alistair.sh>
2192 lines
60 KiB
TypeScript
2192 lines
60 KiB
TypeScript
import { file, gc, Serve, serve, Server } from "bun";
|
||
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test";
|
||
import { readFileSync, writeFileSync } from "fs";
|
||
import { bunEnv, bunExe, dumpStats, isBroken, isIntelMacOS, isIPv4, isIPv6, isPosix, tls, tmpdirSync } from "harness";
|
||
import { join, resolve } from "path";
|
||
// import { renderToReadableStream } from "react-dom/server";
|
||
// import app_jsx from "./app.jsx";
|
||
import { heapStats } from "bun:jsc";
|
||
import { spawn } from "child_process";
|
||
import net from "node:net";
|
||
import { tmpdir } from "os";
|
||
|
||
let renderToReadableStream: any = null;
|
||
let app_jsx: any = null;
|
||
|
||
type Handler = (req: Request) => Response;
|
||
afterEach(() => {
|
||
gc(true);
|
||
});
|
||
|
||
const count = 200;
|
||
let server: Server | undefined;
|
||
|
||
async function runTest({ port, ...serverOptions }: Serve<any>, test: (server: Server) => Promise<void> | void) {
|
||
if (server) {
|
||
server.reload({ ...serverOptions, port: 0 });
|
||
} else {
|
||
while (!server) {
|
||
try {
|
||
server = serve({ ...serverOptions, port: 0 });
|
||
break;
|
||
} catch (e: any) {
|
||
console.log("catch:", e);
|
||
if (e?.message !== `Failed to start server `) {
|
||
throw e;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await test(server);
|
||
}
|
||
|
||
afterAll(() => {
|
||
if (server) {
|
||
server.stop(true);
|
||
server = undefined;
|
||
}
|
||
});
|
||
|
||
it("should be able to abruptly stop the server many times", async () => {
|
||
async function run() {
|
||
const stopped = Promise.withResolvers();
|
||
const server = Bun.serve({
|
||
port: 0,
|
||
error() {
|
||
return new Response("Error", { status: 500 });
|
||
},
|
||
async fetch(req, server) {
|
||
await Bun.sleep(50);
|
||
server.stop(true);
|
||
await Bun.sleep(50);
|
||
server = undefined;
|
||
if (stopped.resolve) {
|
||
stopped.resolve();
|
||
stopped.resolve = undefined;
|
||
}
|
||
|
||
return new Response("Hello, World!");
|
||
},
|
||
});
|
||
const url = server.url;
|
||
|
||
async function request() {
|
||
try {
|
||
await fetch(url, { keepalive: true }).then(res => res.text());
|
||
expect.unreachable();
|
||
} catch (e) {
|
||
expect(["ECONNRESET", "ConnectionRefused"]).toContain(e.code);
|
||
}
|
||
}
|
||
|
||
const requests = new Array(20);
|
||
for (let i = 0; i < 20; i++) {
|
||
requests[i] = request();
|
||
}
|
||
await Promise.all(requests);
|
||
await stopped.promise;
|
||
Bun.gc(true);
|
||
}
|
||
const runs = new Array(10);
|
||
for (let i = 0; i < 10; i++) {
|
||
runs[i] = run();
|
||
}
|
||
|
||
await Promise.all(runs);
|
||
Bun.gc(true);
|
||
});
|
||
|
||
// This test reproduces a crash in Bun v1.1.18 and earlier
|
||
it("should be able to abruptly stop the server", async () => {
|
||
for (let i = 0; i < 2; i++) {
|
||
const controller = new AbortController();
|
||
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
error() {
|
||
return new Response("Error", { status: 500 });
|
||
},
|
||
async fetch(req, server) {
|
||
server.stop(true);
|
||
await Bun.sleep(10);
|
||
return new Response();
|
||
},
|
||
});
|
||
|
||
await fetch(server.url, {
|
||
signal: controller.signal,
|
||
})
|
||
.then(res => {
|
||
return res.blob();
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
});
|
||
|
||
// https://github.com/oven-sh/bun/issues/6758
|
||
// https://github.com/oven-sh/bun/issues/4517
|
||
it("should call cancel() on ReadableStream when the Request is aborted", async () => {
|
||
let waitForCancel = Promise.withResolvers();
|
||
const abortedFn = mock(() => {
|
||
console.log("'abort' event fired", new Date());
|
||
});
|
||
const cancelledFn = mock(() => {
|
||
console.log("'cancel' function called", new Date());
|
||
waitForCancel.resolve();
|
||
});
|
||
let onIncomingRequest = Promise.withResolvers();
|
||
await runTest(
|
||
{
|
||
async fetch(req) {
|
||
req.signal.addEventListener("abort", abortedFn);
|
||
// Give it a chance to start the stream so that the cancel function can be called.
|
||
setTimeout(() => {
|
||
console.log("'onIncomingRequest' function called", new Date());
|
||
onIncomingRequest.resolve();
|
||
}, 0);
|
||
return new Response(
|
||
new ReadableStream({
|
||
async pull(controller) {
|
||
await waitForCancel.promise;
|
||
},
|
||
cancel: cancelledFn,
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const controller = new AbortController();
|
||
const signal = controller.signal;
|
||
const request = fetch(server.url, { signal });
|
||
await onIncomingRequest.promise;
|
||
controller.abort();
|
||
expect(async () => await request).toThrow();
|
||
// Delay for one run of the event loop.
|
||
await Bun.sleep(1);
|
||
|
||
expect(abortedFn).toHaveBeenCalled();
|
||
expect(cancelledFn).toHaveBeenCalled();
|
||
},
|
||
);
|
||
});
|
||
for (let withDelay of [true, false]) {
|
||
for (let connectionHeader of ["keepalive", "not keepalive"] as const) {
|
||
it(`should NOT call cancel() on ReadableStream that finished normally for ${connectionHeader} request and ${withDelay ? "with" : "without"} delay`, async () => {
|
||
const cancelledFn = mock(() => {
|
||
console.log("'cancel' function called", new Date());
|
||
});
|
||
let onIncomingRequest = Promise.withResolvers();
|
||
await runTest(
|
||
{
|
||
async fetch(req) {
|
||
return new Response(
|
||
new ReadableStream({
|
||
async pull(controller) {
|
||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||
if (withDelay) await Bun.sleep(1);
|
||
controller.close();
|
||
},
|
||
cancel: cancelledFn,
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const resp = await fetch(
|
||
server.url,
|
||
connectionHeader === "keepalive"
|
||
? {}
|
||
: {
|
||
headers: {
|
||
"Connection": "close",
|
||
},
|
||
keepalive: false,
|
||
},
|
||
);
|
||
await resp.blob();
|
||
// Delay for one run of the event loop.
|
||
await Bun.sleep(1);
|
||
expect(cancelledFn).not.toHaveBeenCalled();
|
||
},
|
||
);
|
||
});
|
||
}
|
||
}
|
||
describe.todoIf(isBroken && isIntelMacOS)(
|
||
"1000 uploads & downloads in batches of 64 do not leak ReadableStream",
|
||
() => {
|
||
for (let isDirect of [true, false] as const) {
|
||
it(
|
||
isDirect ? "direct" : "default",
|
||
async () => {
|
||
const blob = new Blob([new Uint8Array(1024 * 768).fill(123)]);
|
||
Bun.gc(true);
|
||
|
||
const expected = Bun.CryptoHasher.hash("sha256", blob, "base64");
|
||
const initialCount = heapStats().objectTypeCounts.ReadableStream || 0;
|
||
|
||
await runTest(
|
||
{
|
||
async fetch(req) {
|
||
var hasher = new Bun.SHA256();
|
||
for await (const chunk of req.body) {
|
||
await Bun.sleep(0);
|
||
hasher.update(chunk);
|
||
}
|
||
return new Response(
|
||
isDirect
|
||
? new ReadableStream({
|
||
type: "direct",
|
||
async pull(controller) {
|
||
await Bun.sleep(0);
|
||
controller.write(Buffer.from(hasher.digest("base64")));
|
||
await controller.flush();
|
||
controller.close();
|
||
},
|
||
})
|
||
: new ReadableStream({
|
||
async pull(controller) {
|
||
await Bun.sleep(0);
|
||
controller.enqueue(Buffer.from(hasher.digest("base64")));
|
||
controller.close();
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const count = 1000;
|
||
async function callback() {
|
||
const response = await fetch(server.url, {
|
||
body: blob,
|
||
method: "POST",
|
||
});
|
||
|
||
// We are testing for ReadableStream leaks, so we use the ReadableStream here.
|
||
const chunks = [];
|
||
for await (const chunk of response.body) {
|
||
chunks.push(chunk);
|
||
}
|
||
|
||
const digest = Buffer.from(Bun.concatArrayBuffers(chunks)).toString();
|
||
|
||
expect(digest).toBe(expected);
|
||
Bun.gc(false);
|
||
}
|
||
{
|
||
let remaining = count;
|
||
|
||
const batchSize = 64;
|
||
while (remaining > 0) {
|
||
const promises = new Array(count);
|
||
for (let i = 0; i < batchSize && remaining > 0; i++) {
|
||
promises[i] = callback();
|
||
}
|
||
await Promise.all(promises);
|
||
remaining -= batchSize;
|
||
}
|
||
}
|
||
|
||
Bun.gc(true);
|
||
dumpStats();
|
||
expect(heapStats().objectTypeCounts.ReadableStream).toBeWithin(
|
||
Math.max(initialCount - count / 2, 0),
|
||
initialCount + count / 2,
|
||
);
|
||
},
|
||
);
|
||
},
|
||
100000,
|
||
);
|
||
}
|
||
},
|
||
);
|
||
|
||
[200, 200n, 303, 418, 599, 599n].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(server.url.origin);
|
||
expect(response.status).toBe(Number(statusCode));
|
||
expect(await response.text()).toBe("Foo Bar");
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
[-200, 42, 100, 102, 12345, Math.PI, 999, 600, 199, 199n, 600n, 100n, 102n].forEach(statusCode => {
|
||
it(`should error on invalid HTTP status code (${statusCode})`, async () => {
|
||
await runTest(
|
||
{
|
||
fetch() {
|
||
try {
|
||
return new Response("Foo Bar", { status: statusCode });
|
||
} catch (err) {
|
||
expect(err).toBeInstanceOf(RangeError);
|
||
return new Response("Error!", { status: 500 });
|
||
}
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
expect(response.status).toBe(500);
|
||
expect(await response.text()).toBe("Error!");
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
it("should display a welcome message when the response value type is incorrect", async () => {
|
||
await runTest(
|
||
{
|
||
// @ts-ignore
|
||
fetch(req) {
|
||
return Symbol("invalid response type");
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
const text = await response.text();
|
||
expect(text).toContain("Welcome to Bun!");
|
||
},
|
||
);
|
||
});
|
||
|
||
it("request.signal works in trivial case", async () => {
|
||
var aborty = new AbortController();
|
||
var signaler = Promise.withResolvers();
|
||
await runTest(
|
||
{
|
||
async fetch(req) {
|
||
req.signal.addEventListener("abort", () => {
|
||
signaler.resolve();
|
||
});
|
||
aborty.abort();
|
||
await Bun.sleep(2);
|
||
return new Response("Test failed!");
|
||
},
|
||
},
|
||
async server => {
|
||
expect(async () => {
|
||
const response = await fetch(server.url.origin, {
|
||
signal: aborty.signal,
|
||
});
|
||
await signaler.promise;
|
||
await response.blob();
|
||
}).toThrow("The operation was aborted.");
|
||
},
|
||
);
|
||
});
|
||
|
||
it("request.signal works in leaky case", async () => {
|
||
var aborty = new AbortController();
|
||
var signaler = Promise.withResolvers();
|
||
|
||
await runTest(
|
||
{
|
||
async fetch(req) {
|
||
req.signal.addEventListener("abort", () => {
|
||
signaler.resolve();
|
||
});
|
||
aborty.abort();
|
||
await Bun.sleep(20);
|
||
return new Response("Test failed!");
|
||
},
|
||
},
|
||
async server => {
|
||
await expect(async () => {
|
||
const resp = await fetch(server.url.origin, { signal: aborty.signal });
|
||
await signaler.promise;
|
||
await Bun.sleep(10);
|
||
resp.body?.getReader();
|
||
}).toThrow("The operation was aborted.");
|
||
},
|
||
);
|
||
});
|
||
|
||
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(server.url.origin);
|
||
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: string;
|
||
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 = `${server.url.origin}/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 renders headers, does not call error handler", async () => {
|
||
const onMessage = mock(async url => {
|
||
const response = await fetch(url);
|
||
expect(response.status).toBe(402);
|
||
expect(response.headers.get("X-Hey")).toBe("123");
|
||
expect(response.text()).resolves.toBe("");
|
||
subprocess.kill();
|
||
});
|
||
|
||
await using subprocess = Bun.spawn({
|
||
cwd: import.meta.dirname,
|
||
cmd: [bunExe(), "readable-stream-throws.fixture.js"],
|
||
env: bunEnv,
|
||
stdout: "ignore",
|
||
stderr: "pipe",
|
||
ipc: onMessage,
|
||
});
|
||
|
||
let [exitCode, stderr] = await Promise.all([subprocess.exited, subprocess.stderr.text()]);
|
||
expect(exitCode).toBeInteger();
|
||
expect(stderr).toContain("error: Oops");
|
||
expect(onMessage).toHaveBeenCalled();
|
||
});
|
||
|
||
it("throw on pull after writing should not call the error handler", async () => {
|
||
const onMessage = mock(async href => {
|
||
const url = new URL("write", href);
|
||
const response = await fetch(url);
|
||
expect(response.status).toBe(402);
|
||
expect(response.headers.get("X-Hey")).toBe("123");
|
||
expect(response.text()).resolves.toBe("");
|
||
subprocess.kill();
|
||
});
|
||
|
||
await using subprocess = Bun.spawn({
|
||
cwd: import.meta.dirname,
|
||
cmd: [bunExe(), "readable-stream-throws.fixture.js"],
|
||
env: bunEnv,
|
||
stdout: "ignore",
|
||
stderr: "pipe",
|
||
ipc: onMessage,
|
||
});
|
||
|
||
let [exitCode, stderr] = await Promise.all([subprocess.exited, subprocess.stderr.text()]);
|
||
expect(exitCode).toBeInteger();
|
||
expect(stderr).toContain("error: Oops");
|
||
expect(onMessage).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
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(server.url.origin);
|
||
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(server.url.origin);
|
||
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(server.url.origin);
|
||
expect(response.status).toBe(200);
|
||
expect(await response.text()).toBe("Test Passed");
|
||
},
|
||
);
|
||
});
|
||
|
||
// Also verifies error handler reset in `.reload()` due to test above
|
||
// TODO: rewrite test so uncaught error does not create an annotation in CI
|
||
it.skip("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(server.url.origin);
|
||
expect(response.status).toBe(500);
|
||
},
|
||
);
|
||
});
|
||
|
||
it("text from JS throws on start has error handler", async () => {
|
||
var pass = false;
|
||
var err: Error;
|
||
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(server.url.origin);
|
||
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(
|
||
{
|
||
async fetch(req) {
|
||
return new Response(
|
||
new ReadableStream({
|
||
async start(controller) {
|
||
controller.enqueue(textToExpect.substring(0, 100));
|
||
await Bun.sleep(0);
|
||
queueMicrotask(() => {
|
||
controller.enqueue(textToExpect.substring(100));
|
||
controller.close();
|
||
});
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
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(server.url.origin);
|
||
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({
|
||
async pull(controller) {
|
||
controller.enqueue(textToExpect.substring(0, 100));
|
||
await Bun.sleep(0);
|
||
queueMicrotask(() => {
|
||
controller.enqueue(textToExpect.substring(100));
|
||
controller.close();
|
||
});
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
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 Bun.sleep(0);
|
||
}
|
||
await Bun.sleep(0);
|
||
controller.close();
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
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 Bun.sleep(0);
|
||
controller.enqueue(textToExpect.substring(100));
|
||
await Bun.sleep(0);
|
||
controller.close();
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
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 Bun.sleep(0);
|
||
}
|
||
|
||
controller.enqueue(remain);
|
||
controller.close();
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
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(server.url.origin);
|
||
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(server.url.origin);
|
||
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(server.url.origin);
|
||
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(server.url.origin);
|
||
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(server.url.origin, {
|
||
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(server.url.origin);
|
||
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(server.url.origin);
|
||
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(server.url.origin),
|
||
fetch(server.url.origin),
|
||
fetch(server.url.origin),
|
||
fetch(server.url.origin),
|
||
fetch(server.url.origin),
|
||
]);
|
||
|
||
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(server.url.origin),
|
||
fetch(server.url.origin),
|
||
fetch(server.url.origin),
|
||
fetch(server.url.origin),
|
||
fetch(server.url.origin),
|
||
]);
|
||
|
||
for (let response of responses) {
|
||
expect(await response.text()).toBe(textToExpect);
|
||
}
|
||
i += responses.length;
|
||
}
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
it("should support reloading", async () => {
|
||
const first: Handler = req => new Response("first");
|
||
const second: Handler = req => new Response("second");
|
||
await runTest(
|
||
{
|
||
fetch: first,
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin);
|
||
expect(await response.text()).toBe("first");
|
||
server.reload({ fetch: second });
|
||
const response2 = await fetch(server.url.origin);
|
||
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",
|
||
} as Record<string, string>;
|
||
|
||
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(server.url.origin);
|
||
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(server.url.origin);
|
||
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 getServerWithSize = runTest.bind(null, {
|
||
fetch(req) {
|
||
const { searchParams } = new URL(req.url);
|
||
const start = Number(searchParams.get("start"));
|
||
const end = Number(searchParams.get("end"));
|
||
const file = Bun.file(fixture);
|
||
return new Response(file.slice(start, end), {
|
||
headers: {
|
||
"Content-Range": "bytes " + start + "-" + end + "/" + file.size,
|
||
},
|
||
});
|
||
},
|
||
});
|
||
|
||
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(`${server.url.origin}/?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);
|
||
});
|
||
});
|
||
}
|
||
|
||
for (const [start, end] of good) {
|
||
it(`good range with size: ${start} - ${end}`, async () => {
|
||
await getServerWithSize(async server => {
|
||
const response = await fetch(`${server.url.origin}/?start=${start}&end=${end}`, {
|
||
verbose: true,
|
||
});
|
||
expect(parseInt(response.headers.get("Content-Range")?.split("/")[1])).toEqual(full.byteLength);
|
||
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(`${server.url.origin}/?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(`${server.url.origin}/?start=${start}&end=${end}`);
|
||
const out = await response.arrayBuffer();
|
||
expect(out).toEqual(new ArrayBuffer(0));
|
||
expect(response.status).toBe(206);
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
it("formats error responses correctly", async () => {
|
||
const { promise, resolve, reject } = Promise.withResolvers();
|
||
const c = spawn(bunExe(), ["./error-response.js"], {
|
||
cwd: import.meta.dir,
|
||
env: bunEnv,
|
||
});
|
||
|
||
var output = "";
|
||
c.stderr.on("data", chunk => {
|
||
output += chunk.toString();
|
||
});
|
||
c.stderr.on("end", () => {
|
||
try {
|
||
expect(output).toContain('throw new Error("1");');
|
||
resolve();
|
||
} catch (e) {
|
||
reject(e);
|
||
} finally {
|
||
c.kill();
|
||
}
|
||
});
|
||
await promise;
|
||
});
|
||
|
||
it("request body and signal life cycle", async () => {
|
||
renderToReadableStream = (await import("react-dom/server.browser")).renderToReadableStream;
|
||
app_jsx = (await import("./app")).default;
|
||
{
|
||
const headers = {
|
||
headers: {
|
||
"Content-Type": "text/html",
|
||
},
|
||
};
|
||
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
async fetch(req) {
|
||
return new Response(await renderToReadableStream(app_jsx), headers);
|
||
},
|
||
});
|
||
|
||
for (let j = 0; j < 10; j++) {
|
||
const batchSize = 64;
|
||
const requests = [];
|
||
for (let i = 0; i < batchSize; i++) {
|
||
requests.push(fetch(server.url.origin));
|
||
}
|
||
await Promise.all(requests);
|
||
Bun.gc(true);
|
||
}
|
||
|
||
await Bun.sleep(10);
|
||
expect().pass();
|
||
}
|
||
}, 30_000);
|
||
|
||
it("propagates content-type from a Bun.file()'s file path in fetch()", async () => {
|
||
const body = Bun.file(import.meta.dir + "/fetch.js.txt");
|
||
const bodyText = await body.text();
|
||
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
development: false,
|
||
async fetch(req) {
|
||
expect(req.headers.get("Content-Type")).toBe("text/plain;charset=utf-8");
|
||
const text = await req.text();
|
||
expect(text).toBe(bodyText);
|
||
|
||
return new Response(Bun.file(import.meta.dir + "/fetch.js.txt"));
|
||
},
|
||
});
|
||
|
||
// @ts-ignore
|
||
const reqBody = new Request(server.url.origin, {
|
||
body,
|
||
method: "POST",
|
||
});
|
||
const res = await fetch(reqBody);
|
||
expect(res.status).toBe(200);
|
||
|
||
// but it does for Response
|
||
expect(res.headers.get("Content-Type")).toBe("text/plain;charset=utf-8");
|
||
});
|
||
|
||
it("does propagate type for Blob", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
development: false,
|
||
async fetch(req) {
|
||
expect(req.headers.get("Content-Type")).toBe("text/plain;charset=utf-8");
|
||
return new Response(new Blob(["hey"], { type: "text/plain;charset=utf-8" }));
|
||
},
|
||
});
|
||
|
||
const body = new Blob(["hey"], { type: "text/plain;charset=utf-8" });
|
||
// @ts-ignore
|
||
const res = await fetch(server.url.origin, {
|
||
body,
|
||
method: "POST",
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(res.headers.get("Content-Type")).toBe("text/plain;charset=utf-8");
|
||
});
|
||
|
||
it("unix socket connection in Bun.serve", async () => {
|
||
const unix = join(tmpdir(), "bun." + Date.now() + ((Math.random() * 32) | 0).toString(16) + ".sock");
|
||
using server = Bun.serve({
|
||
unix,
|
||
|
||
async fetch(req) {
|
||
expect(req.headers.get("Content-Type")).toBeNull();
|
||
return new Response(new Blob(["hey"], { type: "text/plain;charset=utf-8" }));
|
||
},
|
||
});
|
||
|
||
const requestText = `GET / HTTP/1.1\r\nHost: localhost\r\n\r\n`;
|
||
const received: Buffer[] = [];
|
||
const { resolve, promise } = Promise.withResolvers();
|
||
await using connection = await Bun.connect({
|
||
unix,
|
||
socket: {
|
||
data(socket, data) {
|
||
received.push(data);
|
||
resolve();
|
||
},
|
||
},
|
||
});
|
||
connection.write(requestText);
|
||
connection.flush();
|
||
await promise;
|
||
expect(Buffer.concat(received).toString()).toEndWith("\r\n\r\nhey");
|
||
connection.end();
|
||
});
|
||
|
||
it("unix socket connection throws an error on a bad domain without crashing", async () => {
|
||
const unix = "/i/don/tevent/exist/because/the/directory/is/invalid/yes.sock";
|
||
expect(() => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
unix,
|
||
|
||
async fetch(req) {
|
||
expect(req.headers.get("Content-Type")).toBeNull();
|
||
return new Response(new Blob(["hey"], { type: "text/plain;charset=utf-8" }));
|
||
},
|
||
});
|
||
}).toThrow();
|
||
});
|
||
|
||
it("#5859 text", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
development: false,
|
||
async fetch(req) {
|
||
return new Response(await req.text(), {});
|
||
},
|
||
});
|
||
|
||
const response = await fetch(server.url.origin, {
|
||
method: "POST",
|
||
body: new Uint8Array([0xfd]),
|
||
});
|
||
|
||
expect(await response.text()).toBe("<22>");
|
||
});
|
||
|
||
it("#5859 json", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
async fetch(req) {
|
||
try {
|
||
const json = await req.json();
|
||
console.log({ json });
|
||
} catch (e) {
|
||
return new Response(e?.message!, { status: 500 });
|
||
}
|
||
|
||
return new Response("SHOULD'VE FAILED", {});
|
||
},
|
||
});
|
||
|
||
const response = await fetch(server.url.origin, {
|
||
method: "POST",
|
||
body: new Uint8Array([0xfd]),
|
||
});
|
||
|
||
expect(await response.text()).toBe("Failed to parse JSON");
|
||
expect(response.ok).toBeFalse();
|
||
});
|
||
|
||
it("#5859 arrayBuffer", async () => {
|
||
const tmp = join(tmpdirSync(), "bad");
|
||
await Bun.write(tmp, new Uint8Array([0xfd]));
|
||
expect(async () => await Bun.file(tmp).json()).toThrow();
|
||
});
|
||
|
||
describe("server.requestIP", () => {
|
||
it.if(isIPv4())("v4", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
fetch(req, server) {
|
||
const ip = server.requestIP(req);
|
||
console.log(ip);
|
||
return Response.json(ip);
|
||
},
|
||
hostname: "127.0.0.1",
|
||
});
|
||
|
||
const response = await fetch(server.url.origin).then(x => x.json());
|
||
expect(response).toMatchObject({
|
||
address: "127.0.0.1",
|
||
family: "IPv4",
|
||
port: expect.any(Number),
|
||
});
|
||
});
|
||
|
||
it.if(isIPv6())("v6", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
fetch(req, server) {
|
||
return Response.json(server.requestIP(req));
|
||
},
|
||
hostname: "::1",
|
||
});
|
||
|
||
const response = await fetch(`http://localhost:${server.port}`).then(x => x.json());
|
||
expect(response).toMatchObject({
|
||
address: "::1",
|
||
family: "IPv6",
|
||
port: expect.any(Number),
|
||
});
|
||
});
|
||
|
||
it.if(isPosix)("server.requestIP (unix)", async () => {
|
||
const unix = join(tmpdirSync(), "serve.sock");
|
||
using server = Bun.serve({
|
||
unix,
|
||
fetch(req, server) {
|
||
return Response.json(server.requestIP(req));
|
||
},
|
||
});
|
||
const requestText = `GET / HTTP/1.1\r\nHost: localhost\r\n\r\n`;
|
||
const received: Buffer[] = [];
|
||
const { resolve, promise } = Promise.withResolvers<void>();
|
||
const connection = await Bun.connect({
|
||
unix,
|
||
socket: {
|
||
data(socket, data) {
|
||
received.push(data);
|
||
resolve();
|
||
},
|
||
},
|
||
});
|
||
connection.write(requestText);
|
||
connection.flush();
|
||
await promise;
|
||
expect(Buffer.concat(received).toString()).toEndWith("\r\n\r\nnull");
|
||
connection.end();
|
||
});
|
||
});
|
||
|
||
it("should response with HTTP 413 when request body is larger than maxRequestBodySize, issue#6031", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
maxRequestBodySize: 10,
|
||
fetch(req, server) {
|
||
return new Response("OK");
|
||
},
|
||
});
|
||
|
||
{
|
||
const resp = await fetch(server.url.origin, {
|
||
method: "POST",
|
||
body: "A".repeat(10),
|
||
});
|
||
expect(resp.status).toBe(200);
|
||
expect(await resp.text()).toBe("OK");
|
||
}
|
||
{
|
||
const resp = await fetch(server.url.origin, {
|
||
method: "POST",
|
||
body: "A".repeat(11),
|
||
});
|
||
expect(resp.status).toBe(413);
|
||
}
|
||
});
|
||
|
||
it("should support promise returned from error", async () => {
|
||
const { promise, resolve } = Promise.withResolvers<string>();
|
||
|
||
await using subprocess = Bun.spawn({
|
||
cwd: import.meta.dirname,
|
||
cmd: [bunExe(), "bun-serve.fixture.js"],
|
||
env: bunEnv,
|
||
stdout: "ignore",
|
||
stderr: "pipe",
|
||
ipc(message) {
|
||
resolve(message);
|
||
},
|
||
});
|
||
|
||
const url = new URL(await promise);
|
||
|
||
{
|
||
const resp = await fetch(new URL("async-fulfilled", url));
|
||
expect(resp.status).toBe(200);
|
||
expect(resp.text()).resolves.toBe("Async fulfilled");
|
||
}
|
||
|
||
{
|
||
const resp = await fetch(new URL("async-rejected", url));
|
||
expect(resp.status).toBe(500);
|
||
}
|
||
|
||
{
|
||
const resp = await fetch(new URL("async-pending", url));
|
||
expect(resp.status).toBe(200);
|
||
expect(resp.text()).resolves.toBe("Async pending");
|
||
}
|
||
|
||
{
|
||
const resp = await fetch(new URL("async-rejected-pending", url));
|
||
expect(resp.status).toBe(500);
|
||
}
|
||
|
||
subprocess.kill();
|
||
});
|
||
|
||
if (process.platform === "linux")
|
||
it("should use correct error when using a root range port(#7187)", () => {
|
||
expect(() => {
|
||
using server = Bun.serve({
|
||
port: 1003,
|
||
fetch(req) {
|
||
return new Response("request answered");
|
||
},
|
||
});
|
||
}).toThrow("permission denied 0.0.0.0:1003");
|
||
});
|
||
|
||
describe.concurrent("should error with invalid options", async () => {
|
||
it("requestCert", () => {
|
||
expect(() => {
|
||
Bun.serve({
|
||
port: 0,
|
||
fetch(req) {
|
||
return new Response("hi");
|
||
},
|
||
tls: {
|
||
requestCert: "invalid",
|
||
},
|
||
});
|
||
}).toThrow("TLSOptions.requestCert must be a boolean");
|
||
});
|
||
it("rejectUnauthorized", () => {
|
||
expect(() => {
|
||
Bun.serve({
|
||
port: 0,
|
||
fetch(req) {
|
||
return new Response("hi");
|
||
},
|
||
tls: {
|
||
rejectUnauthorized: "invalid",
|
||
},
|
||
});
|
||
}).toThrow("TLSOptions.rejectUnauthorized must be a boolean");
|
||
});
|
||
it("lowMemoryMode", () => {
|
||
expect(() => {
|
||
Bun.serve({
|
||
port: 0,
|
||
fetch(req) {
|
||
return new Response("hi");
|
||
},
|
||
tls: {
|
||
rejectUnauthorized: true,
|
||
lowMemoryMode: "invalid",
|
||
},
|
||
});
|
||
}).toThrow("TLSOptions.lowMemoryMode must be a boolean");
|
||
});
|
||
it("multiple missing server name", () => {
|
||
expect(() => {
|
||
Bun.serve({
|
||
port: 0,
|
||
fetch(req) {
|
||
return new Response("hi");
|
||
},
|
||
tls: [
|
||
{
|
||
key: "lkwejflkwjeflkj",
|
||
},
|
||
{
|
||
key: "lkwjefhwlkejfklwj",
|
||
},
|
||
],
|
||
});
|
||
}).toThrow("SNI tls object must have a serverName");
|
||
});
|
||
});
|
||
it.concurrent("should resolve pending promise if requested ended with pending read", async () => {
|
||
let error: Error;
|
||
function shouldError(e: Error) {
|
||
error = e;
|
||
}
|
||
let is_done = false;
|
||
function shouldMarkDone(result: { done: boolean; value: any }) {
|
||
is_done = result.done;
|
||
}
|
||
await runTest(
|
||
{
|
||
fetch(req) {
|
||
// @ts-ignore
|
||
req.body?.getReader().read().then(shouldMarkDone).catch(shouldError);
|
||
return new Response("OK");
|
||
},
|
||
},
|
||
async server => {
|
||
const response = await fetch(server.url.origin, {
|
||
method: "POST",
|
||
body: "1".repeat(64 * 1024),
|
||
});
|
||
const text = await response.text();
|
||
expect(text).toContain("OK");
|
||
expect(is_done).toBe(false);
|
||
expect(error).toBeDefined();
|
||
expect(error.name).toContain("AbortError");
|
||
},
|
||
);
|
||
});
|
||
|
||
it.concurrent("should work with dispose keyword", async () => {
|
||
let url: string;
|
||
{
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
fetch() {
|
||
return new Response("OK");
|
||
},
|
||
});
|
||
url = server.url;
|
||
expect((await fetch(url)).status).toBe(200);
|
||
}
|
||
expect(fetch(url)).rejects.toThrow();
|
||
});
|
||
|
||
// prettier-ignore
|
||
it("should be able to stop in the middle of a file response", async () => {
|
||
async function doRequest(url: string) {
|
||
try {
|
||
const response = await fetch(url, { signal: AbortSignal.timeout(10) });
|
||
const read = (response.body as ReadableStream<any>).getReader();
|
||
while (true) {
|
||
const { value, done } = await read.read();
|
||
if (done) break;
|
||
}
|
||
expect(response.status).toBe(200);
|
||
} catch {}
|
||
}
|
||
const fixture = join(import.meta.dir, "server-bigfile-send.fixture.js");
|
||
for (let i = 0; i < 3; i++) {
|
||
const process = Bun.spawn([bunExe(), fixture], {
|
||
env: bunEnv,
|
||
stderr: "inherit",
|
||
stdout: "pipe",
|
||
stdin: "ignore",
|
||
});
|
||
const { value } = await process.stdout.getReader().read();
|
||
const url = new TextDecoder().decode(value).trim();
|
||
const requests = [];
|
||
for (let j = 0; j < 5_000; j++) {
|
||
requests.push(doRequest(url));
|
||
}
|
||
// only await for 1k requests (and kill the process)
|
||
await Promise.all(requests.slice(0, 1_000));
|
||
expect(process.exitCode || 0).toBe(0);
|
||
process.kill();
|
||
}
|
||
}, 60_000);
|
||
|
||
it("should be able to abrupt stop the server", async () => {
|
||
for (let i = 0; i < 10; i++) {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
error() {
|
||
return new Response("Error", { status: 500 });
|
||
},
|
||
async fetch(req, server) {
|
||
server.stop(true);
|
||
await Bun.sleep(100);
|
||
return new Response("Hello, World!");
|
||
},
|
||
});
|
||
|
||
try {
|
||
await fetch(server.url).then(res => res.text());
|
||
expect.unreachable();
|
||
} catch (e) {
|
||
expect(e.code).toBe("ECONNRESET");
|
||
}
|
||
}
|
||
});
|
||
|
||
it.concurrent("should not instanciate error instances in each request", async () => {
|
||
const startErrorCount = heapStats().objectTypeCounts.Error || 0;
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
async fetch(req, server) {
|
||
return new Response("bun");
|
||
},
|
||
});
|
||
const batchSize = 100;
|
||
const batch = new Array(batchSize);
|
||
for (let i = 0; i < 1000; i++) {
|
||
batch[i % batchSize] = await fetch(server.url, {
|
||
method: "POST",
|
||
body: "bun",
|
||
});
|
||
if (i % batchSize === batchSize - 1) {
|
||
await Promise.all(batch);
|
||
}
|
||
}
|
||
expect(heapStats().objectTypeCounts.Error || 0).toBeLessThanOrEqual(startErrorCount);
|
||
});
|
||
|
||
it("should be able to abort a sendfile response and streams", async () => {
|
||
const bigfile = join(import.meta.dir, "../../web/encoding/utf8-encoding-fixture.bin");
|
||
using server = serve({
|
||
port: 0,
|
||
tls,
|
||
hostname: "localhost",
|
||
async fetch() {
|
||
return new Response(file(bigfile), {
|
||
headers: { "Content-Type": "text/html" },
|
||
});
|
||
},
|
||
});
|
||
|
||
async function doRequest() {
|
||
try {
|
||
const controller = new AbortController();
|
||
const res = await fetch(server.url, {
|
||
signal: controller.signal,
|
||
tls: { rejectUnauthorized: false },
|
||
});
|
||
res.body
|
||
?.getReader()
|
||
.read()
|
||
.catch(() => {});
|
||
controller.abort();
|
||
} catch {}
|
||
}
|
||
const batchSize = 20;
|
||
const batch = [];
|
||
|
||
for (let i = 0; i < 500; i++) {
|
||
batch.push(doRequest());
|
||
if (batch.length === batchSize) {
|
||
await Promise.all(batch);
|
||
batch.length = 0;
|
||
}
|
||
}
|
||
await Promise.all(batch);
|
||
expect().pass();
|
||
}, 10_000);
|
||
|
||
it.concurrent("should not send extra bytes when using sendfile", async () => {
|
||
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||
const tmpFile = join(tmpdirSync(), "test.bin");
|
||
await Bun.write(tmpFile, payload);
|
||
using serve = Bun.serve({
|
||
port: 0,
|
||
fetch(req) {
|
||
const pathname = new URL(req.url).pathname;
|
||
if (pathname === "/file") {
|
||
return new Response(Bun.file(tmpFile), {
|
||
headers: {
|
||
"Content-Type": "plain/text",
|
||
},
|
||
});
|
||
}
|
||
return new Response("Not Found", {
|
||
status: 404,
|
||
});
|
||
},
|
||
});
|
||
|
||
// manually fetch the file using sockets, and get the whole content
|
||
const { promise, resolve, reject } = Promise.withResolvers();
|
||
const socket = net.connect(serve.port, "localhost", () => {
|
||
socket.write("GET /file HTTP/1.1\r\nHost: localhost\r\n\r\n");
|
||
setTimeout(() => {
|
||
socket.end(); // wait a bit before closing the connection so we get the whole content
|
||
}, 100);
|
||
});
|
||
|
||
let body: Buffer | null = null;
|
||
let content_length = 0;
|
||
let headers = "";
|
||
|
||
socket.on("data", data => {
|
||
if (body) {
|
||
body = Buffer.concat([body as Buffer, data]);
|
||
|
||
return;
|
||
}
|
||
// parse headers
|
||
const str = data.toString("utf8");
|
||
const index = str.indexOf("\r\n\r\n");
|
||
if (index === -1) {
|
||
headers += str;
|
||
return;
|
||
}
|
||
headers += str.slice(0, index);
|
||
const lines = headers.split("\r\n");
|
||
for (const line of lines) {
|
||
const [key, value] = line.split(": ");
|
||
if (key.toLowerCase() === "content-length") {
|
||
content_length = Number.parseInt(value, 10);
|
||
}
|
||
}
|
||
body = data.subarray(index + 4);
|
||
});
|
||
socket.on("error", reject);
|
||
socket.on("close", () => {
|
||
resolve(body);
|
||
});
|
||
|
||
expect(await promise).toEqual(Buffer.from(payload));
|
||
expect(content_length).toBe(payload.byteLength);
|
||
});
|
||
|
||
it.concurrent("we should always send date", async () => {
|
||
const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||
const tmpFile = join(tmpdirSync(), "test.bin");
|
||
await Bun.write(tmpFile, payload);
|
||
using serve = Bun.serve({
|
||
port: 0,
|
||
fetch(req) {
|
||
const pathname = new URL(req.url).pathname;
|
||
if (pathname === "/file") {
|
||
return new Response(Bun.file(tmpFile), {
|
||
headers: {
|
||
"Content-Type": "plain/text",
|
||
},
|
||
});
|
||
}
|
||
if (pathname === "/file2") {
|
||
return new Response(Bun.file(tmpFile));
|
||
}
|
||
if (pathname === "/stream") {
|
||
return new Response(
|
||
new ReadableStream({
|
||
async pull(controller) {
|
||
await Bun.sleep(10);
|
||
controller.enqueue(payload);
|
||
await Bun.sleep(10);
|
||
controller.close();
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
return new Response("Hello, World!");
|
||
},
|
||
});
|
||
|
||
{
|
||
const res = await fetch(new URL("/file", serve.url.origin));
|
||
expect(res.headers.has("Date")).toBeTrue();
|
||
}
|
||
{
|
||
const res = await fetch(new URL("/file2", serve.url.origin));
|
||
expect(res.headers.has("Date")).toBeTrue();
|
||
}
|
||
|
||
{
|
||
const res = await fetch(new URL("/", serve.url.origin));
|
||
expect(res.headers.has("Date")).toBeTrue();
|
||
}
|
||
{
|
||
const res = await fetch(new URL("/stream", serve.url.origin));
|
||
expect(res.headers.has("Date")).toBeTrue();
|
||
}
|
||
});
|
||
|
||
it.concurrent(
|
||
"should allow use of custom timeout",
|
||
async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
idleTimeout: 8, // uws precision is in seconds, and lower than 4 seconds is not reliable its timer is not that accurate
|
||
async fetch(req) {
|
||
const url = new URL(req.url);
|
||
return new Response(
|
||
new ReadableStream({
|
||
async pull(controller) {
|
||
controller.enqueue("Hello,");
|
||
if (url.pathname === "/timeout") {
|
||
await Bun.sleep(10000);
|
||
} else {
|
||
await Bun.sleep(10);
|
||
}
|
||
controller.enqueue(" World!");
|
||
|
||
controller.close();
|
||
},
|
||
}),
|
||
{ headers: { "Content-Type": "text/plain" } },
|
||
);
|
||
},
|
||
});
|
||
async function testTimeout(pathname: string, success: boolean) {
|
||
const res = await fetch(new URL(pathname, server.url.origin));
|
||
expect(res.status).toBe(200);
|
||
if (success) {
|
||
expect(res.text()).resolves.toBe("Hello, World!");
|
||
} else {
|
||
expect(res.text()).rejects.toThrow(/The socket connection was closed unexpectedly./);
|
||
}
|
||
}
|
||
await Promise.all([testTimeout("/ok", true), testTimeout("/timeout", false)]);
|
||
},
|
||
15_000,
|
||
);
|
||
|
||
it.concurrent(
|
||
"should reset timeout after writes",
|
||
async () => {
|
||
// the default is 10s so we send 15
|
||
// this test should take 15s at most
|
||
const CHUNKS = 15;
|
||
const payload = Buffer.from(`data: ${Date.now()}\n\n`);
|
||
using server = Bun.serve({
|
||
idleTimeout: 5,
|
||
port: 0,
|
||
fetch(request, server) {
|
||
let controller!: ReadableStreamDefaultController;
|
||
let count = CHUNKS;
|
||
let interval = setInterval(() => {
|
||
controller.enqueue(payload);
|
||
count--;
|
||
if (count == 0) {
|
||
clearInterval(interval);
|
||
interval = null;
|
||
controller.close();
|
||
return;
|
||
}
|
||
}, 1000);
|
||
return new Response(
|
||
new ReadableStream({
|
||
start(_controller) {
|
||
controller = _controller;
|
||
},
|
||
cancel(controller) {
|
||
if (interval) clearInterval(interval);
|
||
},
|
||
}),
|
||
{
|
||
headers: {
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache",
|
||
},
|
||
},
|
||
);
|
||
},
|
||
});
|
||
let received = 0;
|
||
const response = await fetch(server.url);
|
||
const stream = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
while (true) {
|
||
const { done, value } = await stream.read();
|
||
received += value?.length || 0;
|
||
if (done) break;
|
||
}
|
||
|
||
expect(received).toBe(CHUNKS * payload.byteLength);
|
||
},
|
||
20_000,
|
||
);
|
||
|
||
it.concurrent("allow requestIP after async operation", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
async fetch(req, server) {
|
||
await Bun.sleep(1);
|
||
return new Response(JSON.stringify(server.requestIP(req)));
|
||
},
|
||
});
|
||
|
||
const ip = await fetch(server.url).then(res => res.json());
|
||
expect(ip).not.toBeNull();
|
||
expect(ip.port).toBeInteger();
|
||
expect(ip.address).toBeString();
|
||
expect(ip.family).toBeString();
|
||
});
|
||
|
||
it.concurrent(
|
||
"allow custom timeout per request",
|
||
async () => {
|
||
using server = Bun.serve({
|
||
idleTimeout: 1,
|
||
port: 0,
|
||
async fetch(req, server) {
|
||
server.timeout(req, 60);
|
||
await Bun.sleep(10000); //uWS precision is not great
|
||
|
||
return new Response("Hello, World!");
|
||
},
|
||
});
|
||
expect(server.timeout).toBeFunction();
|
||
const res = await fetch(new URL("/long-timeout", server.url.origin));
|
||
expect(res.status).toBe(200);
|
||
expect(res.text()).resolves.toBe("Hello, World!");
|
||
},
|
||
20_000,
|
||
);
|
||
|
||
it.concurrent("#6462", async () => {
|
||
let headers: string[] = [];
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
async fetch(request) {
|
||
for (const key of request.headers.keys()) {
|
||
headers = headers.concat([[key, request.headers.get(key)]]);
|
||
}
|
||
return new Response(
|
||
JSON.stringify({
|
||
"headers": headers,
|
||
}),
|
||
{ status: 200 },
|
||
);
|
||
},
|
||
});
|
||
|
||
const bytes = Buffer.from(`GET / HTTP/1.1\r\nConnection: close\r\nHost: ${server.hostname}\r\nTest!: test\r\n\r\n`);
|
||
const { promise, resolve } = Promise.withResolvers();
|
||
await Bun.connect({
|
||
port: server.port,
|
||
hostname: server.hostname,
|
||
socket: {
|
||
open(socket) {
|
||
const wrote = socket.write(bytes);
|
||
console.log("wrote", wrote);
|
||
},
|
||
data(socket, data) {
|
||
console.log(data.toString("utf8"));
|
||
},
|
||
close(socket) {
|
||
resolve();
|
||
},
|
||
},
|
||
});
|
||
await promise;
|
||
|
||
expect(headers).toStrictEqual([
|
||
["connection", "close"],
|
||
["host", "localhost"],
|
||
["test!", "test"],
|
||
]);
|
||
});
|
||
|
||
it.concurrent("#6583", async () => {
|
||
const callback = mock();
|
||
using server = Bun.serve({
|
||
fetch: callback,
|
||
port: 0,
|
||
hostname: "localhost",
|
||
});
|
||
const { promise, resolve } = Promise.withResolvers();
|
||
await Bun.connect({
|
||
port: server.port,
|
||
hostname: server.hostname,
|
||
tls: true,
|
||
socket: {
|
||
open(socket) {
|
||
socket.write("GET / HTTP/1.1\r\nConnection: close\r\nHost: localhost\r\n\r\n");
|
||
},
|
||
data(socket, data) {
|
||
console.log(data.toString("utf8"));
|
||
},
|
||
close(socket) {
|
||
resolve();
|
||
},
|
||
},
|
||
});
|
||
await promise;
|
||
expect(callback).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.concurrent("do the best effort to flush everything", async () => {
|
||
using server = Bun.serve({
|
||
port: 0,
|
||
async fetch(req) {
|
||
return new Response(
|
||
new ReadableStream({
|
||
type: "direct",
|
||
async pull(ctrl) {
|
||
ctrl.write("b");
|
||
await Bun.sleep(10);
|
||
ctrl.write("un");
|
||
},
|
||
}),
|
||
);
|
||
},
|
||
});
|
||
let response = await fetch(server.url);
|
||
expect(await response.text()).toBe("bun");
|
||
});
|
||
|
||
it.concurrent("#20283", async () => {
|
||
using server = Bun.serve({
|
||
routes: {
|
||
"/": async req => {
|
||
// calling clone() with no cookies should not crash
|
||
const cloned = req.clone();
|
||
return Response.json({
|
||
cookies: req.cookies,
|
||
clonedCookies: cloned.cookies,
|
||
});
|
||
},
|
||
},
|
||
port: 0,
|
||
});
|
||
|
||
const response = await fetch(server.url);
|
||
const json = await response.json();
|
||
// there should be no cookies and the clone should have succeeded
|
||
expect(json).toEqual({ cookies: {}, clonedCookies: {} });
|
||
});
|