From 03de99afcfc51b627a290841baafbff8fb23d662 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 31 Aug 2024 03:32:08 -0700 Subject: [PATCH] Add tests for static routes + support server.reload for static routes (#13643) --- docs/api/http.md | 113 +++++++++++++- packages/bun-uws/src/App.h | 7 + packages/bun-uws/src/HttpContextData.h | 7 + src/bun.js/api/server.zig | 35 ++--- src/deps/libuwsockets.cpp | 14 ++ src/deps/uws.zig | 23 ++- test/js/bun/http/bun-serve-static.test.ts | 177 ++++++++++++++++++++++ 7 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 test/js/bun/http/bun-serve-static.test.ts diff --git a/docs/api/http.md b/docs/api/http.md index d2ee381e67..72f28395d7 100644 --- a/docs/api/http.md +++ b/docs/api/http.md @@ -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. diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 68e56c80a2..be91e146d6 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -514,6 +514,13 @@ public: return std::move(*this); } + void clearRoutes() { + if (httpContext) { + httpContext->getSocketContextData()->clearRoutes(); + } + } + + TemplatedApp &&head(std::string pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { if (httpContext) { httpContext->onHttp("HEAD", pattern, std::move(handler)); diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 920c12a702..502941de87 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -50,6 +50,13 @@ private: void *upgradedWebSocket = nullptr; bool isParsingHttp = false; bool rejectUnauthorized = false; + + // TODO: SNI + void clearRoutes() { + this->router = HttpRouter{}; + this->currentRouter = &router; + filterHandlers.clear(); + } }; } diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 10118daa30..142e1703fc 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -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( diff --git a/src/deps/libuwsockets.cpp b/src/deps/libuwsockets.cpp index b25d7d1e74..540ee430c3 100644 --- a/src/deps/libuwsockets.cpp +++ b/src/deps/libuwsockets.cpp @@ -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) diff --git a/src/deps/uws.zig b/src/deps/uws.zig index 5f5850b9ff..c41aee172e 100644 --- a/src/deps/uws.zig +++ b/src/deps/uws.zig @@ -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; diff --git a/test/js/bun/http/bun-serve-static.test.ts b/test/js/bun/http/bun-serve-static.test.ts new file mode 100644 index 0000000000..9ad9a2ca8a --- /dev/null +++ b/test/js/bun/http/bun-serve-static.test.ts @@ -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); + }); +});