Add tests for static routes + support server.reload for static routes (#13643)

This commit is contained in:
Jarred Sumner
2024-08-31 03:32:08 -07:00
committed by GitHub
parent 9ba63eb522
commit 03de99afcf
7 changed files with 352 additions and 24 deletions

View File

@@ -70,16 +70,37 @@ const server = Bun.serve({
});
```
### `static` responses
### Static routes
Serve static responses by route with the `static` option
Use the `static` option to serve static `Response` objects by route.
```ts
// Bun v1.1.27+ required
Bun.serve({
static: {
// health-check endpoint
"/api/health-check": new Response("All good!"),
// redirect from /old-link to /new-link
"/old-link": Response.redirect("/new-link", 301),
// serve static text
"/": new Response("Hello World"),
// server a file by buffering it in memory
"/index.html": new Response(await Bun.file("./index.html").bytes(), {
headers: {
"Content-Type": "text/html",
},
}),
"/favicon.ico": new Response(await Bun.file("./favicon.ico").bytes(), {
headers: {
"Content-Type": "image/x-icon",
},
}),
// serve JSON
"/api/version.json": Response.json({ version: "1.0.0" }),
},
fetch(req) {
@@ -88,10 +109,77 @@ Bun.serve({
});
```
Static routes support headers, status code, and other `Response` options.
```ts
Bun.serve({
static: {
"/api/time": new Response(new Date().toISOString(), {
headers: {
"X-Custom-Header": "Bun!",
},
}),
},
fetch(req) {
return new Response("404!");
},
});
```
Static routes can serve Response bodies faster than `fetch` handlers because they don't create `Request` objects, they don't create `AbortSignal`, they don't create additional `Response` objects. The only per-request memory allocation is the TCP/TLS socket data needed for each request.
{% note %}
`static` is experimental and may change in the future.
`static` is experimental
{% /note %}
Static route responses are cached for the lifetime of the server object. To reload static routes, call `server.reload(options)`.
```ts
const server = Bun.serve({
static: {
"/api/time": new Response(new Date().toISOString()),
},
fetch(req) {
return new Response("404!");
},
});
// Update the time every second.
setInterval(() => {
server.reload({
static: {
"/api/time": new Response(new Date().toISOString()),
},
fetch(req) {
return new Response("404!");
},
});
}, 1000);
```
Reloading static routes only impact the next request. In-flight requests continue to use the old static routes. After in-flight requests to old static routes are finished, the old static routes are freed from memory.
To simplify error handling, static routes do not support streaming response bodies from `ReadableStream` or an `AsyncIterator`. Fortunately, you can still buffer the response in memory first:
```ts
const time = await fetch("https://api.example.com/v1/data");
// Buffer the response in memory first.
const blob = await time.blob();
const server = Bun.serve({
static: {
"/api/data": new Response(blob),
},
fetch(req) {
return new Response("404!");
},
});
```
### Changing the `port` and `hostname`
To configure which port and hostname the server will listen on, set `port` and `hostname` in the options object.
@@ -348,7 +436,24 @@ Bun.serve({
});
```
## Object syntax
## idleTimeout
To configure the idle timeout, set the `idleTimeout` field in Bun.serve.
```ts
Bun.serve({
// 10 seconds:
idleTimeout: 10,
fetch(req) {
return new Response("Bun!");
},
});
```
This is the maximum amount of time a connection is allowed to be idle before the server closes it. A connection is idling if there is no data sent or received.
## export default syntax
Thus far, the examples on this page have used the explicit `Bun.serve` API. Bun also supports an alternate syntax.

View File

@@ -514,6 +514,13 @@ public:
return std::move(*this);
}
void clearRoutes() {
if (httpContext) {
httpContext->getSocketContextData()->clearRoutes();
}
}
TemplatedApp &&head(std::string pattern, MoveOnlyFunction<void(HttpResponse<SSL> *, HttpRequest *)> &&handler) {
if (httpContext) {
httpContext->onHttp("HEAD", pattern, std::move(handler));

View File

@@ -50,6 +50,13 @@ private:
void *upgradedWebSocket = nullptr;
bool isParsingHttp = false;
bool rejectUnauthorized = false;
// TODO: SNI
void clearRoutes() {
this->router = HttpRouter<RouterData>{};
this->currentRouter = &router;
filterHandlers.clear();
}
};
}

View File

@@ -283,11 +283,16 @@ const StaticRoute = struct {
server.onPendingRequest();
resp.timeout(server.config().idleTimeout);
}
resp.corked(renderMetadata, .{ this, resp });
resp.end("", resp.shouldCloseConnection());
resp.corked(renderMetadataAndEnd, .{ this, resp });
this.onResponseComplete(resp);
}
fn renderMetadataAndEnd(this: *Route, resp: HTTPResponse) void {
this.renderMetadata(resp);
resp.writeHeaderInt("Content-Length", this.cached_blob_size);
resp.endWithoutBody(resp.shouldCloseConnection());
}
pub fn onRequest(this: *Route, req: *uws.Request, resp: HTTPResponse) void {
req.setYield(false);
this.ref();
@@ -344,6 +349,10 @@ const StaticRoute = struct {
}
fn onWritable(this: *Route, write_offset: u64, resp: HTTPResponse) void {
if (this.server) |server| {
resp.timeout(server.config().idleTimeout);
}
if (!this.onWritableBytes(write_offset, resp)) {
this.toAsync(resp);
return;
@@ -5980,6 +5989,8 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp
pub fn onReloadFromZig(this: *ThisServer, new_config: *ServerConfig, globalThis: *JSC.JSGlobalObject) void {
httplog("onReload", .{});
this.app.clearRoutes();
// only reload those two
if (this.config.onRequest != new_config.onRequest) {
this.config.onRequest.unprotect();
@@ -5995,12 +6006,6 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp
if (ws.handler.onMessage != .zero or ws.handler.onOpen != .zero) {
if (this.config.websocket) |old_ws| {
old_ws.unprotect();
} else {
this.app.ws("/*", this, 0, ServerWebSocket.behavior(
ThisServer,
ssl_enabled,
ws.toBehavior(),
));
}
ws.globalObject = globalThis;
@@ -6008,17 +6013,13 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp
} // we don't remove it
}
if (this.config.static_routes.items.len > 0) {
// TODO: clear old static routes
for (this.config.static_routes.items) |*route| {
route.deinit();
}
this.config.static_routes.deinit();
this.config.static_routes = new_config.static_routes;
if (new_config.static_routes.items.len > 0) {
new_config.applyStaticRoutes(
ssl_enabled,
AnyServer.from(this),
this.app,
);
}
this.setRoutes();
}
pub fn onReload(

View File

@@ -26,6 +26,20 @@ extern "C"
return (uws_app_t *)new uWS::App();
}
void uws_app_clear_routes(int ssl, uws_app_t *app)
{
if (ssl)
{
uWS::SSLApp *uwsApp = (uWS::SSLApp *)app;
uwsApp->clearRoutes();
}
else
{
uWS::App *uwsApp = (uWS::App *)app;
uwsApp->clearRoutes();
}
}
void uws_app_get(int ssl, uws_app_t *app, const char *pattern, uws_method_handler handler, void *user_data)
{
if (ssl)

View File

@@ -1963,10 +1963,17 @@ pub const AnyResponse = union(enum) {
};
}
pub fn writeOrEndWithoutBody(this: AnyResponse, data: []const u8) void {
pub fn writeHeaderInt(this: AnyResponse, key: []const u8, value: u64) void {
return switch (this) {
.SSL => |resp| resp.writeOrEndWithoutBody(data),
.TCP => |resp| resp.writeOrEndWithoutBody(data),
.SSL => |resp| resp.writeHeaderInt(key, value),
.TCP => |resp| resp.writeHeaderInt(key, value),
};
}
pub fn endWithoutBody(this: AnyResponse, close_connection: bool) void {
return switch (this) {
.SSL => |resp| resp.endWithoutBody(close_connection),
.TCP => |resp| resp.endWithoutBody(close_connection),
};
}
@@ -2070,6 +2077,14 @@ pub fn NewApp(comptime ssl: bool) type {
return uws_app_destroy(ssl_flag, @as(*uws_app_s, @ptrCast(app)));
}
pub fn clearRoutes(app: *ThisApp) void {
if (comptime is_bindgen) {
unreachable;
}
return uws_app_clear_routes(ssl_flag, @as(*uws_app_t, @ptrCast(app)));
}
fn RouteHandler(comptime UserDataType: type, comptime handler: fn (UserDataType, *Request, *Response) void) type {
return struct {
pub fn handle(res: *uws_res, req: *Request, user_data: ?*anyopaque) callconv(.C) void {
@@ -3291,3 +3306,5 @@ extern fn bun_clear_loop_at_thread_exit() void;
pub fn onThreadExit() void {
bun_clear_loop_at_thread_exit();
}
extern fn uws_app_clear_routes(ssl_flag: c_int, app: *uws_app_t) void;

View File

@@ -0,0 +1,177 @@
import { test, expect, mock, beforeAll, describe, afterAll, it } from "bun:test";
import { server } from "bun";
import { fillRepeating, isWindows } from "harness";
const routes = {
"/foo": new Response("foo", {
headers: {
"Content-Type": "text/plain",
"X-Foo": "bar",
},
}),
"/big": new Response(
(() => {
const buf = Buffer.alloc(1024 * 1024 * 4);
for (let i = 0; i < 1024; i++) {
buf[i] = (Math.random() * 256) | 0;
}
fillRepeating(buf, 0, 1024);
return buf;
})(),
),
"/redirect": Response.redirect("/foo/bar", 302),
"/foo/bar": new Response("/foo/bar", {
headers: {
"Content-Type": "text/plain",
"X-Foo": "bar",
},
}),
"/redirect/fallback": Response.redirect("/foo/bar/fallback", 302),
};
const static_responses = {};
for (const [path, response] of Object.entries(routes)) {
static_responses[path] = await response.clone().blob();
}
describe("static", () => {
let server: Server;
let handler = mock(req => {
return new Response(req.url, {
headers: {
...req.headers,
Location: undefined,
},
});
});
afterAll(() => {
server.stop(true);
});
beforeAll(async () => {
server = Bun.serve({
static: routes,
port: 0,
fetch: handler,
});
server.unref();
});
it("reload", async () => {
const modified = { ...routes };
modified["/foo"] = new Response("modified", {
headers: {
"Content-Type": "text/plain",
},
});
server.reload({
static: modified,
fetch: handler,
});
const res = await fetch(`${server.url}foo`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("modified");
server.reload({
static: routes,
fetch: handler,
});
});
describe.each(["/foo", "/big", "/foo/bar"])("%s", path => {
it("GET", async () => {
const previousCallCount = handler.mock.calls.length;
const res = await fetch(`${server.url}${path}`);
expect(res.status).toBe(200);
expect(await res.bytes()).toEqual(await static_responses[path].bytes());
expect(handler.mock.calls.length, "Handler should not be called").toBe(previousCallCount);
});
it("HEAD", async () => {
const previousCallCount = handler.mock.calls.length;
const res = await fetch(`${server.url}${path}`, { method: "HEAD" });
expect(res.status).toBe(200);
expect(await res.bytes()).toHaveLength(0);
expect(res.headers.get("Content-Length")).toBe(static_responses[path].size.toString());
expect(handler.mock.calls.length, "Handler should not be called").toBe(previousCallCount);
});
it(
"stress",
async () => {
const bytes = await static_responses[path].arrayBuffer();
// macOS limits backlog to 128.
// When we do the big request, reduce number of connections but increase number of iterations
const batchSize = Math.ceil((bytes.size > 1024 * 1024 ? 48 : 64) / (isWindows ? 8 : 1));
const iterations = Math.ceil((bytes.size > 1024 * 1024 ? 10 : 12) / (isWindows ? 8 : 1));
async function iterate() {
let array = new Array(batchSize);
const route = `${server.url}${path.substring(1)}`;
for (let i = 0; i < batchSize; i++) {
array[i] = fetch(route)
.then(res => {
expect(res.status).toBe(200);
expect(res.url).toBe(route);
return res.arrayBuffer();
})
.then(output => {
expect(output).toStrictEqual(bytes);
});
}
await Promise.all(array);
console.count("Iteration: " + path);
Bun.gc();
}
for (let i = 0; i < iterations; i++) {
await iterate();
}
Bun.gc(true);
const baseline = (process.memoryUsage.rss() / 1024 / 1024) | 0;
console.log("Baseline RSS", baseline);
for (let i = 0; i < iterations; i++) {
await iterate();
console.log("RSS", (process.memoryUsage.rss() / 1024 / 1024) | 0);
}
Bun.gc(true);
const rss = (process.memoryUsage.rss() / 1024 / 1024) | 0;
expect(rss).toBeLessThan(baseline * 4);
},
30 * 1000,
);
});
it("/redirect", async () => {
const previousCallCount = handler.mock.calls.length;
const res = await fetch(`${server.url}/redirect`, { redirect: "manual" });
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe("/foo/bar");
expect(handler.mock.calls.length, "Handler should not be called").toBe(previousCallCount);
});
it("/redirect (follow)", async () => {
const previousCallCount = handler.mock.calls.length;
const res = await fetch(`${server.url}/redirect`);
expect(res.status).toBe(200);
expect(res.url).toBe(`${server.url}foo/bar`);
expect(await res.text()).toBe("/foo/bar");
expect(handler.mock.calls.length, "Handler should not be called").toBe(previousCallCount);
expect(res.redirected).toBeTrue();
});
it("/redirect/fallback", async () => {
const previousCallCount = handler.mock.calls.length;
const res = await fetch(`${server.url}/redirect/fallback`);
expect(res.status).toBe(200);
expect(await res.text()).toBe(`${server.url}foo/bar/fallback`);
expect(handler.mock.calls.length, "Handler should be called").toBe(previousCallCount + 1);
});
});