From f29e912a91c9ff92e0a75690d220c3cfd32c2a3b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 16 Feb 2025 00:42:05 -0800 Subject: [PATCH] Add routes to Bun.serve() (#17357) --- .cursor/rules/javascriptcore-class.mdc | 38 +- bench/snippets/decode.js | 71 +++ packages/bun-types/bun.d.ts | 265 ++++++++--- src/bun.js/ConsoleObject.zig | 2 +- src/bun.js/api/BunObject.zig | 5 +- src/bun.js/api/server.classes.ts | 1 + src/bun.js/api/server.zig | 334 ++++++++++++-- src/bun.js/bindings/JSBunRequest.cpp | 161 +++++++ src/bun.js/bindings/JSBunRequest.h | 44 ++ src/bun.js/bindings/JSSocketAddress.zig | 11 + src/bun.js/bindings/ServerRouteList.cpp | 300 ++++++++++++ src/bun.js/bindings/ServerRouteList.h | 6 + src/bun.js/bindings/ZigGlobalObject.cpp | 22 +- src/bun.js/bindings/ZigGlobalObject.h | 3 + src/bun.js/bindings/bindings.zig | 2 + .../bindings/decodeURIComponentSIMD.cpp | 315 +++++++++++++ src/bun.js/bindings/decodeURIComponentSIMD.h | 7 + .../bindings/webcore/DOMClientIsoSubspaces.h | 3 + src/bun.js/bindings/webcore/DOMIsoSubspaces.h | 2 + src/bun.js/test/pretty_format.zig | 2 +- src/bun.js/webcore/request.zig | 33 +- src/bun.js/webcore/response.classes.ts | 1 + src/deps/uws.zig | 21 + src/js/internal-for-testing.ts | 6 + test/js/bun/http/bun-serve-routes.test.ts | 433 ++++++++++++++++++ .../bun/http/decodeURIComponentSIMD.test.ts | 372 +++++++++++++++ 26 files changed, 2331 insertions(+), 129 deletions(-) create mode 100644 bench/snippets/decode.js create mode 100644 src/bun.js/bindings/JSBunRequest.cpp create mode 100644 src/bun.js/bindings/JSBunRequest.h create mode 100644 src/bun.js/bindings/JSSocketAddress.zig create mode 100644 src/bun.js/bindings/ServerRouteList.cpp create mode 100644 src/bun.js/bindings/ServerRouteList.h create mode 100644 src/bun.js/bindings/decodeURIComponentSIMD.cpp create mode 100644 src/bun.js/bindings/decodeURIComponentSIMD.h create mode 100644 test/js/bun/http/bun-serve-routes.test.ts create mode 100644 test/js/bun/http/decodeURIComponentSIMD.test.ts diff --git a/.cursor/rules/javascriptcore-class.mdc b/.cursor/rules/javascriptcore-class.mdc index 6a6ebc6498..de5a088f7b 100644 --- a/.cursor/rules/javascriptcore-class.mdc +++ b/.cursor/rules/javascriptcore-class.mdc @@ -268,16 +268,16 @@ If there's a class, prototype, and constructor: 2. Initialize the class structure in [ZigGlobalObject.cpp](mdc:src/bun.js/bindings/ZigGlobalObject.cpp) in `void GlobalObject::finishCreation(VM& vm)` 3. Visit the class structure in visitChildren in [ZigGlobalObject.cpp](mdc:src/bun.js/bindings/ZigGlobalObject.cpp) in `void GlobalObject::visitChildrenImpl` -```c++ - +```c++#ZigGlobalObject.cpp +void GlobalObject::finishCreation(VM& vm) { +// ... m_JSStatsBigIntClassStructure.initLater( [](LazyClassStructure::Initializer& init) { + // Call the function to initialize our class structure. Bun::initJSBigIntStatsClassStructure(init); }); ``` -If there's only a class, use `JSC::LazyProperty` instead of `JSC::LazyClassStructure`. - Then, implement the function that creates the structure: ```c++ void setupX509CertificateClassStructure(LazyClassStructure::Initializer& init) @@ -296,6 +296,36 @@ void setupX509CertificateClassStructure(LazyClassStructure::Initializer& init) } ``` +If there's only a class, use `JSC::LazyProperty` instead of `JSC::LazyClassStructure`: + +1. Add the `JSC::LazyProperty` to @ZigGlobalObject.h +2. Initialize the class structure in @ZigGlobalObject.cpp in `void GlobalObject::finishCreation(VM& vm)` +3. Visit the lazy property in visitChildren in @ZigGlobalObject.cpp in `void GlobalObject::visitChildrenImpl` +void GlobalObject::finishCreation(VM& vm) { +// ... + this.m_myLazyProperty.initLater([](const JSC::LazyProperty::Initializer& init) { + init.set(Bun::initMyStructure(init.vm, reinterpret_cast(init.owner))); + }); +``` + +Then, implement the function that creates the structure: +```c++ +Structure* setupX509CertificateStructure(JSC::VM &vm, Zig::GlobalObject* globalObject) +{ + // If there is a prototype: + auto* prototypeStructure = JSX509CertificatePrototype::createStructure(init.vm, init.global, init.global->objectPrototype()); + auto* prototype = JSX509CertificatePrototype::create(init.vm, init.global, prototypeStructure); + + // If there is no prototype or it only has + + auto* structure = JSX509Certificate::createStructure(init.vm, init.global, prototype); + init.setPrototype(prototype); + init.setStructure(structure); + init.setConstructor(constructor); +} +``` + + Then, use the structure by calling `globalObject.m_myStructureName.get(globalObject)` ```C++ diff --git a/bench/snippets/decode.js b/bench/snippets/decode.js new file mode 100644 index 0000000000..16b1255cda --- /dev/null +++ b/bench/snippets/decode.js @@ -0,0 +1,71 @@ +import { bench, run } from "../runner.mjs"; + +let decodeURIComponentSIMD; +if (typeof Bun !== "undefined") { + ({ decodeURIComponentSIMD } = await import("bun:internal-for-testing")); +} + +const hugeText = Buffer.alloc(1000000, "Hello, world!").toString(); +const hugeTextWithPercentAtEnd = Buffer.alloc(1000000, "Hello, world!%40").toString(); + +const tinyText = Buffer.alloc(100, "Hello, world!").toString(); +const tinyTextWithPercentAtEnd = Buffer.alloc(100, "Hello, world!%40").toString(); + +const veryTinyText = Buffer.alloc(8, "a").toString(); +const veryTinyTextWithPercentAtEnd = Buffer.alloc(8, "a%40").toString(); + +decodeURIComponentSIMD && + bench("decodeURIComponentSIMD - no % x 8 bytes", () => { + decodeURIComponentSIMD(veryTinyText); + }); + +bench(" decodeURIComponent - no % x 8 bytes", () => { + decodeURIComponent(veryTinyText); +}); + +decodeURIComponentSIMD && + bench("decodeURIComponentSIMD - yes % x 8 bytes", () => { + decodeURIComponentSIMD(veryTinyTextWithPercentAtEnd); + }); + +bench(" decodeURIComponent - yes % x 8 bytes", () => { + decodeURIComponent(veryTinyTextWithPercentAtEnd); +}); + +decodeURIComponentSIMD && + bench("decodeURIComponentSIMD - no % x 100 bytes", () => { + decodeURIComponentSIMD(tinyText); + }); + +bench(" decodeURIComponent - no % x 100 bytes", () => { + decodeURIComponent(tinyText); +}); + +decodeURIComponentSIMD && + bench("decodeURIComponentSIMD - yes % x 100 bytes", () => { + decodeURIComponentSIMD(tinyTextWithPercentAtEnd); + }); + +bench(" decodeURIComponent - yes % x 100 bytes", () => { + decodeURIComponent(tinyTextWithPercentAtEnd); +}); + +decodeURIComponentSIMD && + bench("decodeURIComponentSIMD - no % x 1 MB", () => { + decodeURIComponentSIMD(hugeText); + }); + +bench(" decodeURIComponent - no % x 1 MB", () => { + decodeURIComponent(hugeText); +}); + +decodeURIComponentSIMD && + bench("decodeURIComponentSIMD - yes % x 1 MB", () => { + decodeURIComponentSIMD(hugeTextWithPercentAtEnd); + }); + +bench(" decodeURIComponent - yes % x 1 MB", () => { + decodeURIComponent(hugeTextWithPercentAtEnd); +}); + +await run(); diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 265e952c63..42dd4e7efd 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -465,44 +465,178 @@ declare module "bun" { | UnixWebSocketServeOptions | UnixTLSWebSocketServeOptions; - /** - * Start a fast HTTP server. - * - * @param options Server options (port defaults to $PORT || 3000) - * - * ----- - * - * @example - * - * ```ts - * Bun.serve({ - * fetch(req: Request): Response | Promise { - * return new Response("Hello World!"); - * }, - * - * // Optional port number - the default value is 3000 - * port: process.env.PORT || 3000, - * }); - * ``` - * ----- - * - * @example - * - * Send a file - * - * ```ts - * Bun.serve({ - * fetch(req: Request): Response | Promise { - * return new Response(Bun.file("./package.json")); - * }, - * - * // Optional port number - the default value is 3000 - * port: process.env.PORT || 3000, - * }); - * ``` - */ - // eslint-disable-next-line @definitelytyped/no-unnecessary-generics - function serve(options: Serve): Server; + /** + Bun.serve provides a high-performance HTTP server with built-in routing support. + It enables both function-based and object-based route handlers with type-safe + parameters and method-specific handling. + + @example Basic Usage + ```ts + Bun.serve({ + port: 3000, + fetch(req) { + return new Response("Hello World"); + } + }); + ``` + + @example Route-based Handlers + ```ts + Bun.serve({ + routes: { + // Static responses + "/": new Response("Home page"), + + // Function handlers with type-safe parameters + "/users/:id": (req) => { + // req.params.id is typed as string + return new Response(`User ${req.params.id}`); + }, + + // Method-specific handlers + "/api/posts": { + GET: () => new Response("Get posts"), + POST: async (req) => { + const body = await req.json(); + return new Response("Created post"); + }, + DELETE: (req) => new Response("Deleted post") + }, + + // Wildcard routes + "/static/*": (req) => { + // Handle any path under /static/ + return new Response("Static file"); + }, + + // Disable route (fall through to fetch handler) + "/api/legacy": false + }, + + // Fallback handler for unmatched routes + fetch(req) { + return new Response("Not Found", { status: 404 }); + } + }); + ``` + + @example Path Parameters + ```ts + Bun.serve({ + routes: { + // Single parameter + "/users/:id": (req: BunRequest<"/users/:id">) => { + return new Response(`User ID: ${req.params.id}`); + }, + + // Multiple parameters + "/posts/:postId/comments/:commentId": ( + req: BunRequest<"/posts/:postId/comments/:commentId"> + ) => { + return new Response(JSON.stringify(req.params)); + // Output: {"postId": "123", "commentId": "456"} + } + } + }); + ``` + + @example Route Precedence + ```ts + // Routes are matched in the following order: + // 1. Exact static routes ("/about") + // 2. Parameter routes ("/users/:id") + // 3. Wildcard routes ("/api/*") + + Bun.serve({ + routes: { + "/api/users": () => new Response("Users list"), + "/api/users/:id": (req) => new Response(`User ${req.params.id}`), + "/api/*": () => new Response("API catchall"), + "/*": () => new Response("Root catchall") + } + }); + ``` + + @example Error Handling + ```ts + Bun.serve({ + routes: { + "/error": () => { + throw new Error("Something went wrong"); + } + }, + error(error) { + // Custom error handler + console.error(error); + return new Response(`Error: ${error.message}`, { + status: 500 + }); + } + }); + ``` + + @example Server Lifecycle + ```ts + const server = Bun.serve({ + // Server config... + }); + + // Update routes at runtime + server.reload({ + routes: { + "/": () => new Response("Updated route") + } + }); + + // Stop the server + server.stop(); + ``` + + @example Development Mode + ```ts + Bun.serve({ + development: true, // Enable hot reloading + routes: { + // Routes will auto-reload on changes + } + }); + ``` + + @example Type-Safe Request Handling + ```ts + type Post = { + id: string; + title: string; + }; + + Bun.serve({ + routes: { + "/api/posts/:id": async ( + req: BunRequest<"/api/posts/:id"> + ) => { + if (req.method === "POST") { + const body: Post = await req.json(); + return Response.json(body); + } + return new Response("Method not allowed", { + status: 405 + }); + } + } + }); + ``` + @param options - Server configuration options + @param options.routes - Route definitions mapping paths to handlers + */ + function serve }>( + options: Serve & { + routes?: R; + /** + * @deprecated Use {@link routes} instead in new code + */ + static?: R; + }, + ): Server; /** * Synchronously resolve a `moduleId` as though it were imported from `parent` @@ -3685,6 +3819,30 @@ declare module "bun" { }; } + namespace RouterTypes { + type ExtractRouteParams = T extends `${string}:${infer Param}/${infer Rest}` + ? { [K in Param]: string } & ExtractRouteParams + : T extends `${string}:${infer Param}` + ? { [K in Param]: string } + : T extends `${string}*` + ? {} + : {}; + + type RouteHandler = (req: BunRequest, server: Server) => Response | Promise; + + type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; + + type RouteHandlerObject = { + [K in HTTPMethod]?: RouteHandler; + }; + + type RouteValue = Response | false | RouteHandler | RouteHandlerObject; + } + + interface BunRequest extends Request { + params: RouterTypes.ExtractRouteParams; + } + interface GenericServeOptions { /** * What URI should be used to make {@link Request.url} absolute? @@ -3746,37 +3904,6 @@ declare module "bun" { * This string will currently do nothing. But in the future it could be useful for logs or metrics. */ id?: string | null; - - /** - * Server static Response objects by route. - * - * @example - * ```ts - * Bun.serve({ - * static: { - * "/": new Response("Hello World"), - * "/about": new Response("About"), - * }, - * fetch(req) { - * return new Response("Fallback response"); - * }, - * }); - * ``` - * - * @experimental - */ - static?: Record< - `/${string}`, - | Response - /** - * An HTML import. - */ - | HTMLBundle - /** - * false to force fetch() to handle the route - */ - | false - >; } interface ServeOptions extends GenericServeOptions { diff --git a/src/bun.js/ConsoleObject.zig b/src/bun.js/ConsoleObject.zig index b4cf5e4495..0a5f2d5b64 100644 --- a/src/bun.js/ConsoleObject.zig +++ b/src/bun.js/ConsoleObject.zig @@ -2540,7 +2540,7 @@ pub const Formatter = struct { response.writeFormat(ConsoleObject.Formatter, this, writer_, enable_ansi_colors) catch {}; return; } else if (value.as(JSC.WebCore.Request)) |request| { - request.writeFormat(ConsoleObject.Formatter, this, writer_, enable_ansi_colors) catch {}; + request.writeFormat(value, ConsoleObject.Formatter, this, writer_, enable_ansi_colors) catch {}; return; } else if (value.as(JSC.API.BuildArtifact)) |build| { build.writeFormat(ConsoleObject.Formatter, this, writer_, enable_ansi_colors) catch {}; diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index 454e42de98..4abd69469a 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -3141,11 +3141,14 @@ pub fn serve(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.J if (globalObject.hasException()) { return .zero; } - server.listen(); + const route_list_object = server.listen(); if (globalObject.hasException()) { return .zero; } const obj = server.toJS(globalObject); + if (route_list_object != .zero) { + ServerType.routeListSetCached(obj, globalObject, route_list_object); + } obj.protect(); server.thisObject = obj; diff --git a/src/bun.js/api/server.classes.ts b/src/bun.js/api/server.classes.ts index 34b08171dd..093ddbf956 100644 --- a/src/bun.js/api/server.classes.ts +++ b/src/bun.js/api/server.classes.ts @@ -83,6 +83,7 @@ function generate(name) { finalize: true, construct: true, noConstructor: true, + values: ["routeList"], }); } export default [ diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 04fdabefa7..6e47aa92ed 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -149,6 +149,40 @@ fn getContentType(headers: ?*JSC.FetchHeaders, blob: *const JSC.WebCore.AnyBlob, return .{ content_type, needs_content_type, content_type_needs_free }; } +fn validateRouteName(global: *JSC.JSGlobalObject, path: []const u8) !void { + // Already validated by the caller + bun.debugAssert(path.len > 0 and path[0] == '/'); + + // For now, we don't support params that start with a number. + // Mostly because it makes the params object more complicated to implement and it's easier to cut scope this way for now. + var remaining = path; + var duped_route_names = bun.StringHashMap(void).init(bun.default_allocator); + defer duped_route_names.deinit(); + while (strings.indexOfChar(remaining, ':')) |index| { + remaining = remaining[index + 1 ..]; + const end = strings.indexOfChar(remaining, '/') orelse remaining.len; + const route_name = remaining[0..end]; + if (route_name.len > 0 and std.ascii.isDigit(route_name[0])) { + return global.throwTODO( + \\Route parameter names cannot start with a number. + \\ + \\If you run into this, please file an issue and we will add support for it. + ); + } + + const entry = duped_route_names.getOrPut(route_name) catch bun.outOfMemory(); + if (entry.found_existing) { + return global.throwTODO( + \\Support for duplicate route parameter names is not yet implemented. + \\ + \\If you run into this, please file an issue and we will add support for it. + ); + } + + remaining = remaining[end..]; + } +} + fn writeHeaders( headers: *JSC.FetchHeaders, comptime ssl: bool, @@ -294,6 +328,31 @@ pub const ServerInitContext = struct { framework_router_list: std.ArrayList(bun.bake.Framework.FileSystemRouterType), }; +const UserRouteBuilder = struct { + route: RouteDeclaration, + callback: JSC.Strong = .{}, + + // We need to be able to apply the route to multiple Apps even when there is only one RouteList. + pub const RouteDeclaration = struct { + path: [:0]const u8 = "", + method: union(enum) { + any: void, + specific: HTTP.Method, + } = .any, + + pub fn deinit(this: *RouteDeclaration) void { + if (this.path.len > 0) { + bun.default_allocator.free(this.path); + } + } + }; + + pub fn deinit(this: *UserRouteBuilder) void { + this.route.deinit(); + this.callback.deinit(); + } +}; + pub const ServerConfig = struct { address: union(enum) { tcp: struct { @@ -340,8 +399,11 @@ pub const ServerConfig = struct { allow_hot: bool = true, ipv6_only: bool = false, + had_routes_object: bool = false, + static_routes: std.ArrayList(StaticRouteEntry) = std.ArrayList(StaticRouteEntry).init(bun.default_allocator), negative_routes: std.ArrayList([:0]const u8) = std.ArrayList([:0]const u8).init(bun.default_allocator), + user_routes_to_build: std.ArrayList(UserRouteBuilder) = std.ArrayList(UserRouteBuilder).init(bun.default_allocator), bake: ?bun.bake.UserOptions = null, @@ -505,6 +567,11 @@ pub const ServerConfig = struct { if (this.bake) |*bake| { bake.deinit(); } + + for (this.user_routes_to_build.items) |*builder| { + builder.deinit(); + } + this.user_routes_to_build.clearAndFree(); } pub fn computeID(this: *const ServerConfig, allocator: std.mem.Allocator) []const u8 { @@ -1237,10 +1304,11 @@ pub const ServerConfig = struct { } if (global.hasException()) return error.JSError; - if ((try arg.get(global, "static")) orelse (try arg.get(global, "routes"))) |static| { + if ((try arg.get(global, "routes")) orelse (try arg.get(global, "static"))) |static| { if (!static.isObject()) { return global.throwInvalidArguments("Bun.serve expects 'static' to be an object shaped like { [pathname: string]: Response }", .{}); } + args.had_routes_object = true; var iter = try JSC.JSPropertyIterator(.{ .skip_empty_name = true, @@ -1275,7 +1343,7 @@ pub const ServerConfig = struct { const path, const is_ascii = key.toOwnedSliceReturningAllASCII(bun.default_allocator) catch bun.outOfMemory(); errdefer bun.default_allocator.free(path); - const value = iter.value; + const value: JSC.JSValue = iter.value; if (value.isUndefined()) { continue; @@ -1296,6 +1364,55 @@ pub const ServerConfig = struct { continue; } + if (value.isCallable(global.vm())) { + try validateRouteName(global, path); + args.user_routes_to_build.append(.{ + .route = .{ + .path = bun.default_allocator.dupeZ(u8, path) catch bun.outOfMemory(), + .method = .any, + }, + .callback = JSC.Strong.create(value.withAsyncContextIfNeeded(global), global), + }) catch bun.outOfMemory(); + bun.default_allocator.free(path); + continue; + } else if (value.isObject()) { + const methods = .{ + HTTP.Method.CONNECT, + HTTP.Method.DELETE, + HTTP.Method.GET, + HTTP.Method.HEAD, + HTTP.Method.OPTIONS, + HTTP.Method.PATCH, + HTTP.Method.POST, + HTTP.Method.PUT, + HTTP.Method.TRACE, + }; + var found = false; + inline for (methods) |method| { + if (value.getOwn(global, @tagName(method))) |function| { + if (!function.isCallable(global.vm())) { + return global.throwInvalidArguments("Expected {s} to be a function", .{@tagName(method)}); + } + if (!found) { + try validateRouteName(global, path); + } + found = true; + args.user_routes_to_build.append(.{ + .route = .{ + .path = bun.default_allocator.dupeZ(u8, path) catch bun.outOfMemory(), + .method = .{ .specific = method }, + }, + .callback = JSC.Strong.create(function.withAsyncContextIfNeeded(global), global), + }) catch bun.outOfMemory(); + } + } + + if (found) { + bun.default_allocator.free(path); + continue; + } + } + const route = try AnyRoute.fromJS(global, path, value, &init_ctx); args.static_routes.append(.{ .path = path, @@ -6235,6 +6352,11 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp dev_server: ?*bun.bake.DevServer, + /// These associate a route to the index in RouteList.cpp. + /// User routes may get applied multiple times due to SNI. + /// So we have to store it. + user_routes: std.ArrayListUnmanaged(UserRoute) = .{}, + pub const doStop = JSC.wrapInstanceMethod(ThisServer, "stopFromJS", false); pub const dispose = JSC.wrapInstanceMethod(ThisServer, "disposeFromJS", false); pub const doUpgrade = JSC.wrapInstanceMethod(ThisServer, "onUpgrade", false); @@ -6244,6 +6366,16 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp pub const doRequestIP = JSC.wrapInstanceMethod(ThisServer, "requestIP", false); pub const doTimeout = JSC.wrapInstanceMethod(ThisServer, "timeout", false); + const UserRoute = struct { + id: u32, + server: *ThisServer, + route: UserRouteBuilder.RouteDeclaration, + + pub fn deinit(this: *UserRoute) void { + this.route.deinit(); + } + }; + /// Returns: /// - .ready if no plugin has to be loaded /// - .err if there is a cached failure. Currently, this requires restarting the entire server. @@ -6287,21 +6419,11 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp return globalThis.throw("Server() is not a constructor", .{}); } - extern fn JSSocketAddress__create(global: *JSC.JSGlobalObject, ip: JSValue, port: i32, is_ipv6: bool) JSValue; - pub fn requestIP(this: *ThisServer, request: *JSC.WebCore.Request) JSC.JSValue { if (this.config.address == .unix) { return JSValue.jsNull(); } - return if (request.request_context.getRemoteSocketInfo()) |info| - JSSocketAddress__create( - this.globalThis, - bun.String.createUTF8ForJS(this.globalThis, info.ip), - info.port, - info.is_ipv6, - ) - else - JSValue.jsNull(); + return request.getRemoteSocketInfo(this.globalThis) orelse .null; } pub fn memoryCost(this: *ThisServer) usize { @@ -6601,7 +6723,22 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp this.config.negative_routes.clearAndFree(); this.config.negative_routes = new_config.negative_routes; - this.setRoutes(); + if (new_config.had_routes_object) { + for (this.config.user_routes_to_build.items) |*route| { + route.deinit(); + } + this.config.user_routes_to_build.clearAndFree(); + this.config.user_routes_to_build = new_config.user_routes_to_build; + for (this.user_routes.items) |*route| { + route.deinit(); + } + this.user_routes.clearAndFree(bun.default_allocator); + } + + const route_list_value = this.setRoutes(); + if (new_config.had_routes_object) { + NamespaceType.routeListSetCached(this.thisObject, this.globalThis, route_list_value); + } } pub fn reloadStaticRoutes(this: *ThisServer) !bool { @@ -6611,7 +6748,10 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } this.config = try this.config.cloneForReloadingStaticRoutes(); this.app.?.clearRoutes(); - this.setRoutes(); + const route_list_value = this.setRoutes(); + if (route_list_value != .zero) { + NamespaceType.routeListSetCached(this.thisObject, this.globalThis, route_list_value); + } return true; } @@ -6833,11 +6973,9 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp var is_ipv6: bool = false; if (listener.socket().localAddressText(&buf, &is_ipv6)) |slice| { - var ip = bun.String.createUTF8(slice); - defer ip.deref(); - return JSSocketAddress__create( + return JSC.JSSocketAddress.create( this.globalThis, - ip.toJS(this.globalThis), + slice, port, is_ipv6, ); @@ -7051,6 +7189,10 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp httplog("deinit", .{}); this.cached_hostname.deref(); this.all_closed_promise.deinit(); + for (this.user_routes.items) |*user_route| { + user_route.deinit(); + } + this.user_routes.deinit(bun.default_allocator); this.config.deinit(); if (this.app) |app| { @@ -7341,20 +7483,26 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp return false; } - pub fn onRequest(this: *ThisServer, req: *uws.Request, resp: *App.Response) void { - // Track this before we enter JavaScript. + pub fn onUserRouteRequest(user_route: *UserRoute, req: *uws.Request, resp: *App.Response) void { + const server = user_route.server; + const index = user_route.id; + var should_deinit_context = false; - const prepared = this.prepareJsRequestContext(req, resp, &should_deinit_context) orelse return; + var prepared = server.prepareJsRequestContext(req, resp, &should_deinit_context, false) orelse return; + + const server_request_list = NamespaceType.routeListGetCached(server.thisObject).?; + var response_value = Bun__ServerRouteList__callRoute(server.globalThis, index, prepared.request_object, server.thisObject, server_request_list, &prepared.js_request, req); + + if (server.globalThis.tryTakeException()) |exception| { + response_value = exception; + } + + server.handleRequest(&should_deinit_context, prepared, req, response_value); + } + + fn handleRequest(this: *ThisServer, should_deinit_context: *bool, prepared: PreparedRequest, req: *uws.Request, response_value: JSC.JSValue) void { const ctx = prepared.ctx; - bun.assert(this.config.onRequest != .zero); - - const response_value = this.config.onRequest.call(this.globalThis, this.thisObject, &.{ - prepared.js_request, - this.thisObject, - }) catch |err| - this.globalThis.takeException(err); - defer { // uWS request will not live longer than this function prepared.request_object.request_context.detachRequest(); @@ -7366,7 +7514,7 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp ctx.defer_deinit_until_callback_completes = null; - if (should_deinit_context) { + if (should_deinit_context.*) { ctx.deinit(); return; } @@ -7381,6 +7529,21 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp ctx.toAsync(req, prepared.request_object); } + pub fn onRequest(this: *ThisServer, req: *uws.Request, resp: *App.Response) void { + var should_deinit_context = false; + const prepared = this.prepareJsRequestContext(req, resp, &should_deinit_context, true) orelse return; + + bun.assert(this.config.onRequest != .zero); + + const response_value = this.config.onRequest.call(this.globalThis, this.thisObject, &.{ + prepared.js_request, + this.thisObject, + }) catch |err| + this.globalThis.takeException(err); + + this.handleRequest(&should_deinit_context, prepared, req, response_value); + } + pub fn onRequestFromSaved( this: *ThisServer, req: SavedRequest.Union, @@ -7390,7 +7553,7 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp extra_args: [arg_count]JSValue, ) void { const prepared: PreparedRequest = switch (req) { - .stack => |r| this.prepareJsRequestContext(r, resp, null) orelse return, + .stack => |r| this.prepareJsRequestContext(r, resp, null, true) orelse return, .saved => |data| .{ .js_request = data.js_request.get() orelse @panic("Request was unexpectedly freed"), .request_object = data.request, @@ -7462,7 +7625,7 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } }; - pub fn prepareJsRequestContext(this: *ThisServer, req: *uws.Request, resp: *App.Response, should_deinit_context: ?*bool) ?PreparedRequest { + pub fn prepareJsRequestContext(this: *ThisServer, req: *uws.Request, resp: *App.Response, should_deinit_context: ?*bool, create_js_request: bool) ?PreparedRequest { JSC.markBinding(@src()); this.onPendingRequest(); if (comptime Environment.isDebug) { @@ -7555,7 +7718,7 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } return .{ - .js_request = request_object.toJS(this.globalThis), + .js_request = if (create_js_request) request_object.toJS(this.globalThis) else .zero, .request_object = request_object, .ctx = ctx, }; @@ -7624,7 +7787,8 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp ctx.toAsync(req, request_object); } - fn setRoutes(this: *ThisServer) void { + fn setRoutes(this: *ThisServer) JSC.JSValue { + var route_list_value = JSC.JSValue.zero; // TODO: move devserver and plugin logic away const app = this.app.?; const any_server = AnyServer.from(this); @@ -7638,6 +7802,54 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp var needs_plugins = dev_server != null; var has_html_catch_all = false; + if (this.config.user_routes_to_build.items.len > 0) { + var user_routes_to_build = this.config.user_routes_to_build.moveToUnmanaged(); + var old_user_routes = this.user_routes; + + defer { + for (old_user_routes.items) |*route| { + route.route.deinit(); + } + + old_user_routes.deinit(bun.default_allocator); + } + this.user_routes = std.ArrayListUnmanaged(UserRoute).initCapacity(bun.default_allocator, user_routes_to_build.items.len) catch bun.outOfMemory(); + const paths = bun.default_allocator.alloc(ZigString, user_routes_to_build.items.len) catch bun.outOfMemory(); + const callbacks = bun.default_allocator.alloc(JSC.JSValue, user_routes_to_build.items.len) catch bun.outOfMemory(); + defer bun.default_allocator.free(paths); + defer bun.default_allocator.free(callbacks); + + for (user_routes_to_build.items, paths, callbacks, 0..) |*route, *path, *callback, i| { + path.* = ZigString.init(route.route.path); + callback.* = route.callback.get().?; + this.user_routes.appendAssumeCapacity(.{ + .id = @truncate(i), + .server = this, + .route = route.route, + }); + route.route = .{}; + } + + route_list_value = Bun__ServerRouteList__create(this.globalThis, callbacks.ptr, paths.ptr, user_routes_to_build.items.len); + + for (user_routes_to_build.items) |*route| { + route.deinit(); + } + user_routes_to_build.deinit(bun.default_allocator); + } + + // This may get applied multiple times. + for (this.user_routes.items) |*user_route| { + switch (user_route.route.method) { + .any => { + app.any(user_route.route.path, *UserRoute, user_route, onUserRouteRequest); + }, + .specific => |method| { + app.method(method, user_route.route.path, *UserRoute, user_route, onUserRouteRequest); + }, + } + } + // negative routes have backwards precedence. for (this.config.negative_routes.items) |route| { // Since .applyStaticRoute does head, we need to do it first here too. @@ -7722,13 +7934,16 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp app.connect("/*", *ThisServer, this, onRequest); } } + + return route_list_value; } // TODO: make this return JSError!void, and do not deinitialize on synchronous failure, to allow errdefer in caller scope - pub fn listen(this: *ThisServer) void { + pub fn listen(this: *ThisServer) JSC.JSValue { httplog("listen", .{}); var app: *App = undefined; const globalThis = this.globalThis; + var route_list_value = JSC.JSValue.zero; if (ssl_enabled) { BoringSSL.load(); const ssl_config = this.config.ssl_config orelse @panic("Assertion failure: ssl_config"); @@ -7743,12 +7958,12 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp this.app = null; this.deinit(); - return; + return .zero; }; this.app = app; - this.setRoutes(); + route_list_value = this.setRoutes(); // add serverName to the SSL context using default ssl options if (ssl_config.server_name) |server_name_ptr| { @@ -7762,21 +7977,21 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } this.deinit(); - return; + return .zero; }; if (throwSSLErrorIfNecessary(globalThis)) { this.deinit(); - return; + return .zero; } app.domain(server_name); if (throwSSLErrorIfNecessary(globalThis)) { this.deinit(); - return; + return .zero; } // Ensure the routes are set for that domain name. - this.setRoutes(); + _ = this.setRoutes(); } } @@ -7793,18 +8008,18 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } this.deinit(); - return; + return .zero; }; app.domain(sni_servername); if (throwSSLErrorIfNecessary(globalThis)) { this.deinit(); - return; + return .zero; } // Ensure the routes are set for that domain name. - this.setRoutes(); + _ = this.setRoutes(); } } } @@ -7814,11 +8029,11 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp globalThis.throw("Failed to create HTTP server", .{}) catch {}; } this.deinit(); - return; + return .zero; }; this.app = app; - this.setRoutes(); + route_list_value = this.setRoutes(); } switch (this.config.address) { @@ -7857,7 +8072,7 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp if (globalThis.hasException()) { this.deinit(); - return; + return .zero; } this.ref(); @@ -7868,6 +8083,8 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } else { this.vm.eventLoop().performGC(); } + + return route_list_value; } }; } @@ -8013,8 +8230,8 @@ pub const AnyServer = union(enum) { global: *JSC.JSGlobalObject, ) ?SavedRequest { return switch (server) { - inline .HTTPServer, .DebugHTTPServer => |s| (s.prepareJsRequestContext(req, resp.TCP, null) orelse return null).save(global, req, resp.TCP), - inline .HTTPSServer, .DebugHTTPSServer => |s| (s.prepareJsRequestContext(req, resp.SSL, null) orelse return null).save(global, req, resp.SSL), + inline .HTTPServer, .DebugHTTPServer => |s| (s.prepareJsRequestContext(req, resp.TCP, null, true) orelse return null).save(global, req, resp.TCP), + inline .HTTPSServer, .DebugHTTPSServer => |s| (s.prepareJsRequestContext(req, resp.SSL, null, true) orelse return null).save(global, req, resp.SSL), }; } @@ -8075,3 +8292,20 @@ fn throwSSLErrorIfNecessary(globalThis: *JSC.JSGlobalObject) bool { return false; } + +extern "c" fn Bun__ServerRouteList__callRoute( + globalObject: *JSC.JSGlobalObject, + index: u32, + requestPtr: *Request, + serverObject: JSC.JSValue, + routeListObject: JSC.JSValue, + requestObject: *JSC.JSValue, + req: *uws.Request, +) JSC.JSValue; + +extern "c" fn Bun__ServerRouteList__create( + globalObject: *JSC.JSGlobalObject, + callbacks: [*]JSC.JSValue, + paths: [*]ZigString, + pathsLength: usize, +) JSC.JSValue; diff --git a/src/bun.js/bindings/JSBunRequest.cpp b/src/bun.js/bindings/JSBunRequest.cpp new file mode 100644 index 0000000000..82f87ca1dc --- /dev/null +++ b/src/bun.js/bindings/JSBunRequest.cpp @@ -0,0 +1,161 @@ +#include "root.h" + +#include +#include +#include +#include "JSBunRequest.h" +#include "ZigGlobalObject.h" +#include "AsyncContextFrame.h" +#include + +namespace Bun { + +static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetParams); + +static const HashTableValue JSBunRequestPrototypeValues[] = { + { "params"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetParams, nullptr } }, +}; + +JSBunRequest* JSBunRequest::create(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr, JSObject* params) +{ + JSBunRequest* ptr = new (NotNull, JSC::allocateCell(vm)) JSBunRequest(vm, structure, sinkPtr); + ptr->finishCreation(vm, params); + return ptr; +} + +JSC::Structure* JSBunRequest::createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) +{ + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(static_cast(0b11101110), StructureFlags), info()); +} + +JSC::GCClient::IsoSubspace* JSBunRequest::subspaceForImpl(JSC::VM& vm) +{ + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForBunRequest.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForBunRequest = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForBunRequest.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForBunRequest = std::forward(space); }); +} + +JSObject* JSBunRequest::params() const +{ + if (m_params) { + return m_params.get(); + } + return nullptr; +} + +void JSBunRequest::setParams(JSObject* params) +{ + m_params.set(Base::vm(), this, params); +} + +JSBunRequest::JSBunRequest(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr) + : Base(vm, structure, sinkPtr) +{ +} +extern "C" size_t Request__estimatedSize(void* requestPtr); +extern "C" void Bun__JSRequest__calculateEstimatedByteSize(void* requestPtr); +void JSBunRequest::finishCreation(JSC::VM& vm, JSObject* params) +{ + Base::finishCreation(vm); + m_params.setMayBeNull(vm, this, params); + Bun__JSRequest__calculateEstimatedByteSize(this->wrapped()); + + auto size = Request__estimatedSize(this->wrapped()); + vm.heap.reportExtraMemoryAllocated(this, size); +} + +template +void JSBunRequest::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSBunRequest* thisCallSite = jsCast(cell); + Base::visitChildren(thisCallSite, visitor); + visitor.append(thisCallSite->m_params); +} + +DEFINE_VISIT_CHILDREN(JSBunRequest); + +class JSBunRequestPrototype final : public JSNonFinalObject { +public: + using Base = JSNonFinalObject; + + static JSBunRequestPrototype* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + { + auto* ptr = new (NotNull, JSC::allocateCell(vm)) JSBunRequestPrototype(vm, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + static Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + auto* structure = Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info(), NonArray); + structure->setMayBePrototype(true); + return structure; + } + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSBunRequestPrototype, Base); + return &vm.plainObjectSpace(); + } + +private: + JSBunRequestPrototype(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) + { + Base::finishCreation(vm); + reifyStaticProperties(vm, JSBunRequest::info(), JSBunRequestPrototypeValues, *this); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); + } +}; + +const JSC::ClassInfo JSBunRequestPrototype::s_info = { "BunRequest"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSBunRequestPrototype) }; +const JSC::ClassInfo JSBunRequest::s_info = { "BunRequest"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSBunRequest) }; + +JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetParams, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + JSBunRequest* request = jsDynamicCast(JSValue::decode(thisValue)); + if (!request) + return JSValue::encode(jsUndefined()); + + auto* params = request->params(); + if (!params) { + auto* prototype = defaultGlobalObject(globalObject)->m_JSBunRequestParamsPrototype.get(globalObject); + params = JSC::constructEmptyObject(globalObject, prototype); + request->setParams(params); + } + + return JSValue::encode(params); +} + +Structure* createJSBunRequestStructure(JSC::VM& vm, Zig::GlobalObject* globalObject) +{ + auto prototypeStructure = JSBunRequestPrototype::createStructure(vm, globalObject, globalObject->JSRequestPrototype()); + auto* prototype = JSBunRequestPrototype::create(vm, globalObject, prototypeStructure); + return JSBunRequest::createStructure(vm, globalObject, prototype); +} + +extern "C" EncodedJSValue Bun__getParamsIfBunRequest(JSC::EncodedJSValue thisValue) +{ + if (auto* request = jsDynamicCast(JSValue::decode(thisValue))) { + auto* params = request->params(); + if (!params) { + return JSValue::encode(jsUndefined()); + } + + return JSValue::encode(params); + } + + return JSValue::encode({}); +} + +} // namespace Bun diff --git a/src/bun.js/bindings/JSBunRequest.h b/src/bun.js/bindings/JSBunRequest.h new file mode 100644 index 0000000000..92a5d936fa --- /dev/null +++ b/src/bun.js/bindings/JSBunRequest.h @@ -0,0 +1,44 @@ +#pragma once + +#include "root.h" +#include "ZigGeneratedClasses.h" + +namespace Bun { +using namespace JSC; +using namespace WebCore; + +class JSBunRequest : public JSRequest { +public: + using Base = JSRequest; + + static JSBunRequest* create(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr, JSObject* params); + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if (mode == JSC::SubspaceAccess::Concurrently) { + return nullptr; + } + + return subspaceForImpl(vm); + } + + static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + + DECLARE_VISIT_CHILDREN; + DECLARE_INFO; + + JSObject* params() const; + void setParams(JSObject* params); + +private: + JSBunRequest(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr); + void finishCreation(JSC::VM& vm, JSObject* params); + + mutable JSC::WriteBarrier m_params; +}; + +JSC::Structure* createJSBunRequestStructure(JSC::VM&, Zig::GlobalObject*); + +} // namespace Bun diff --git a/src/bun.js/bindings/JSSocketAddress.zig b/src/bun.js/bindings/JSSocketAddress.zig new file mode 100644 index 0000000000..aa40c3176b --- /dev/null +++ b/src/bun.js/bindings/JSSocketAddress.zig @@ -0,0 +1,11 @@ +pub const JSSocketAddress = opaque { + extern fn JSSocketAddress__create(global: *JSC.JSGlobalObject, ip: JSValue, port: i32, is_ipv6: bool) JSValue; + + pub fn create(global: *JSC.JSGlobalObject, ip: []const u8, port: i32, is_ipv6: bool) JSValue { + return JSSocketAddress__create(global, bun.String.createUTF8ForJS(global, ip), port, is_ipv6); + } +}; + +const bun = @import("root").bun; +const JSC = bun.JSC; +const JSValue = JSC.JSValue; diff --git a/src/bun.js/bindings/ServerRouteList.cpp b/src/bun.js/bindings/ServerRouteList.cpp new file mode 100644 index 0000000000..6e3bbd9965 --- /dev/null +++ b/src/bun.js/bindings/ServerRouteList.cpp @@ -0,0 +1,300 @@ +#include "root.h" +#include +#include +#include "ZigGlobalObject.h" +#include +#include +#include "ZigGeneratedClasses.h" +#include "AsyncContextFrame.h" +#include "ServerRouteList.h" +#include "decodeURIComponentSIMD.h" +#include "JSBunRequest.h" +#include + +namespace Bun { +using namespace JSC; +using namespace WebCore; + +/** + ServerRouteList holds all the callbacks used by routes in Bun.serve() + + The easier approach would be an std.ArrayList of JSC.Strong in Zig, but that + would mean that now we're holding a Strong reference for every single + callback. This would show up in profiling, and it's a lot of strong + references. We could use a JSArray instead, but that would incur unnecessary + overhead when reading values from the array. + + So instead, we have this class that uses a FixedVector of WriteBarriers to + the JSCell of the callback. + + When the ServerRouteList is destroyed, it will clear the FixedVector and + allow the callbacks to be GC'd. + + This also lets us hold structures for the params objects for each route, which + we create lazily. This makes it fast to create the params object for a route. + and is generally better for the JIT since it will see the same structure repeatedly. + */ +class ServerRouteList final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + struct IdentifierRange { + uint16_t start; + uint16_t count; + }; + + static ServerRouteList* create( + JSC::VM& vm, + JSC::Structure* structure, + std::span callbacks, + std::span paths) + { + auto* routeList = new (NotNull, JSC::allocateCell(vm)) ServerRouteList(vm, structure, callbacks, paths); + routeList->finishCreation(vm, callbacks, paths); + return routeList; + } + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject) + { + return JSC::Structure::create(vm, globalObject, globalObject->nullPrototype(), JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static void destroy(JSCell* cell) + { + static_cast(cell)->~ServerRouteList(); + } + + ~ServerRouteList() + { + m_routes.clear(); + m_paramsObjectStructures.clear(); + m_pathIdentifiers.clear(); + m_pathIdentifierRanges.clear(); + } + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForServerRouteList.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForServerRouteList = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForServerRouteList.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForServerRouteList = std::forward(space); }); + } + + DECLARE_INFO; + DECLARE_VISIT_CHILDREN; + + JSValue callRoute(Zig::GlobalObject* globalObject, uint32_t index, void* requestPtr, EncodedJSValue serverObject, EncodedJSValue* requestObject, uWS::HttpRequest* req); + +private: + Structure* structureForParamsObject(JSC::VM& vm, JSC::JSGlobalObject* globalObject, uint32_t index, std::span identifiers); + JSObject* paramsObjectForRoute(JSC::VM& vm, JSC::JSGlobalObject* globalObject, uint32_t index, uWS::HttpRequest* req); + + ServerRouteList(JSC::VM& vm, JSC::Structure* structure, std::span callbacks, std::span paths) + : Base(vm, structure) + , m_routes(callbacks.size()) + , m_paramsObjectStructures(paths.size()) + , m_pathIdentifierRanges(paths.size() * 2) + { + ASSERT(callbacks.size() == paths.size()); + } + + WTF::FixedVector> m_routes; + WTF::FixedVector> m_paramsObjectStructures; + WTF::FixedVector m_pathIdentifierRanges; + WTF::Vector m_pathIdentifiers; + + void finishCreation(JSC::VM& vm, std::span callbacks, std::span paths) + { + Base::finishCreation(vm); + ASSERT(callbacks.size() == paths.size()); + + for (size_t i = 0; i < callbacks.size(); i++) { + this->m_routes.at(i).setMayBeNull(vm, this, JSValue::decode(callbacks[i]).asCell()); + this->m_paramsObjectStructures.at(i).setMayBeNull(vm, this, nullptr); + } + + std::span pathIdentifierRanges = m_pathIdentifierRanges.mutableSpan(); + + for (size_t i = 0; i < paths.size(); i++) { + ZigString rawPath = paths[i]; + WTF::String path = Zig::toString(rawPath); + uint32_t originalIdentifierIndex = m_pathIdentifiers.size(); + size_t startOfIdentifier = 0; + size_t identifierCount = 0; + for (size_t j = 0; j < path.length(); j++) { + switch (path[j]) { + case '/': { + if (startOfIdentifier && startOfIdentifier < j) { + WTF::String&& identifier = path.substring(startOfIdentifier, j - startOfIdentifier); + m_pathIdentifiers.append(JSC::Identifier::fromString(vm, identifier)); + identifierCount++; + } + startOfIdentifier = 0; + break; + } + case ':': { + startOfIdentifier = j + 1; + break; + } + default: { + break; + } + } + } + if (startOfIdentifier && startOfIdentifier < path.length()) { + WTF::String&& identifier = path.substring(startOfIdentifier, path.length() - startOfIdentifier); + m_pathIdentifiers.append(JSC::Identifier::fromString(vm, identifier)); + identifierCount++; + } + + pathIdentifierRanges[0] = { static_cast(originalIdentifierIndex), static_cast(identifierCount) }; + pathIdentifierRanges = pathIdentifierRanges.subspan(1); + } + } +}; + +const JSC::ClassInfo ServerRouteList::s_info = { "ServerRouteList"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(ServerRouteList) }; + +template +void ServerRouteList::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + ServerRouteList* thisCallSite = jsCast(cell); + Base::visitChildren(thisCallSite, visitor); + + for (unsigned i = 0; i < thisCallSite->m_routes.size(); i++) { + if (thisCallSite->m_routes[i]) visitor.append(thisCallSite->m_routes[i]); + if (thisCallSite->m_paramsObjectStructures[i]) visitor.append(thisCallSite->m_paramsObjectStructures[i]); + } +} +DEFINE_VISIT_CHILDREN(ServerRouteList); + +Structure* ServerRouteList::structureForParamsObject(JSC::VM& vm, JSC::JSGlobalObject* globalObject, uint32_t index, std::span identifiers) +{ + + if (identifiers.empty()) { + return globalObject->nullPrototypeObjectStructure(); + } + + if (!m_paramsObjectStructures.at(index)) { + auto* zigGlobalObject = defaultGlobalObject(globalObject); + auto* prototype = zigGlobalObject->m_JSBunRequestParamsPrototype.get(zigGlobalObject); + unsigned inlineCapacity = std::min(identifiers.size(), static_cast(JSC::JSFinalObject::maxInlineCapacity)); + auto* structure = Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, StructureFlags), JSFinalObject::info(), NonArray, inlineCapacity); + + if (identifiers.size() < JSC::JSFinalObject::maxInlineCapacity) { + PropertyOffset offset; + for (const auto& identifier : identifiers) { + structure = structure->addPropertyTransition(vm, structure, identifier, JSC::PropertyAttribute::DontDelete | 0, offset); + } + } + m_paramsObjectStructures.at(index).set(vm, this, structure); + return structure; + } + + return m_paramsObjectStructures.at(index).get(); +} + +JSObject* ServerRouteList::paramsObjectForRoute(JSC::VM& vm, JSC::JSGlobalObject* globalObject, uint32_t index, uWS::HttpRequest* req) +{ + + // Ensure that the object doesn't get GC'd before we've had a chance to initialize it. + MarkedArgumentBuffer args; + IdentifierRange range = m_pathIdentifierRanges.at(index); + size_t offset = range.start; + size_t identifierCount = range.count; + args.ensureCapacity(identifierCount); + + for (size_t i = 0; i < identifierCount; i++) { + auto param = req->getParameter(static_cast(i)); + if (!param.empty()) { + const std::span paramBytes(reinterpret_cast(param.data()), param.size()); + args.append(jsString(vm, decodeURIComponentSIMD(paramBytes))); + } else { + args.append(jsEmptyString(vm)); + } + } + + const std::span identifiers = m_pathIdentifiers.subspan(offset, identifierCount); + + auto* structure = structureForParamsObject(vm, globalObject, index, identifiers); + JSObject* object = constructEmptyObject(vm, structure); + + if (identifierCount < JSC::JSFinalObject::maxInlineCapacity) { + for (size_t i = 0; i < identifierCount; i++) { + object->putDirectOffset(vm, i, args.at(i)); + } + } else { + for (size_t i = 0; i < identifierCount; i++) { + object->putDirect(vm, identifiers[i], args.at(i)); + } + } + + return object; +} + +JSValue ServerRouteList::callRoute(Zig::GlobalObject* globalObject, uint32_t index, void* requestPtr, EncodedJSValue serverObject, EncodedJSValue* requestObject, uWS::HttpRequest* req) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* structure = globalObject->m_JSBunRequestStructure.get(globalObject); + + auto* params = paramsObjectForRoute(vm, globalObject, index, req); + + JSBunRequest* request = JSBunRequest::create( + vm, + structure, + requestPtr, + params); + ASSERT(!scope.exception()); + *requestObject = JSValue::encode(request); + + JSValue callback = m_routes.at(index).get(); + ASSERT(callback); + JSValue serverValue = JSValue::decode(serverObject); + MarkedArgumentBuffer args; + args.append(request); + args.append(serverValue); + + return AsyncContextFrame::call(globalObject, callback, serverValue, args); +} + +extern "C" JSC::EncodedJSValue Bun__ServerRouteList__callRoute( + Zig::GlobalObject* globalObject, + uint32_t index, + void* requestPtr, + JSC::EncodedJSValue serverObject, + JSC::EncodedJSValue routeListObject, + JSC::EncodedJSValue* requestObject, + uWS::HttpRequest* req) +{ + JSValue routeListValue = JSValue::decode(routeListObject); + ServerRouteList* routeList = jsCast(routeListValue); + return JSValue::encode(routeList->callRoute(globalObject, index, requestPtr, serverObject, requestObject, req)); +} + +extern "C" JSC::EncodedJSValue Bun__ServerRouteList__create(Zig::GlobalObject* globalObject, EncodedJSValue* callbacks, ZigString* paths, size_t pathsLength) +{ + auto* structure = globalObject->m_ServerRouteListStructure.get(globalObject); + auto* routeList = ServerRouteList::create(globalObject->vm(), structure, std::span(callbacks, pathsLength), std::span(paths, pathsLength)); + return JSValue::encode(routeList); +} + +Structure* createServerRouteListStructure(JSC::VM& vm, Zig::GlobalObject* globalObject) +{ + return ServerRouteList::createStructure(vm, globalObject); +} + +JSObject* createJSBunRequestParamsPrototype(JSC::VM& vm, Zig::GlobalObject* globalObject) +{ + auto* prototype = constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + prototype->putDirect(vm, vm.propertyNames->toStringTagSymbol, jsString(vm, String("RequestParams"_s)), JSC::PropertyAttribute::DontEnum | 0); + auto* structure = Structure::create(vm, globalObject, prototype, TypeInfo(ObjectType, JSC::JSFinalObject::StructureFlags), JSFinalObject::info(), NonArray); + structure->setMayBePrototype(true); + return JSC::constructEmptyObject(vm, structure); +} + +} // namespace Bun diff --git a/src/bun.js/bindings/ServerRouteList.h b/src/bun.js/bindings/ServerRouteList.h new file mode 100644 index 0000000000..f81169624d --- /dev/null +++ b/src/bun.js/bindings/ServerRouteList.h @@ -0,0 +1,6 @@ + + +namespace Bun { +JSC::Structure* createServerRouteListStructure(JSC::VM&, Zig::GlobalObject*); +JSC::JSObject* createJSBunRequestParamsPrototype(JSC::VM&, Zig::GlobalObject*); +} diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 5f7026c408..20e32a7b4e 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -165,6 +165,9 @@ #include "ProcessBindingBuffer.h" #include "NodeValidator.h" +#include "JSBunRequest.h" +#include "ServerRouteList.h" + #if ENABLE(REMOTE_INSPECTOR) #include "JavaScriptCore/RemoteInspectorServer.h" #endif @@ -3080,6 +3083,21 @@ void GlobalObject::finishCreation(VM& vm) Bun::NapiPrototype::createStructure(init.vm, init.owner, init.owner->objectPrototype())); }); + m_ServerRouteListStructure.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(Bun::createServerRouteListStructure(init.vm, reinterpret_cast(init.owner))); + }); + + m_JSBunRequestParamsPrototype.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(Bun::createJSBunRequestParamsPrototype(init.vm, reinterpret_cast(init.owner))); + }); + + m_JSBunRequestStructure.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(Bun::createJSBunRequestStructure(init.vm, reinterpret_cast(init.owner))); + }); + m_NapiHandleScopeImplStructure.initLater([](const JSC::LazyProperty::Initializer& init) { init.set(Bun::NapiHandleScopeImpl::createStructure(init.vm, init.owner)); }); @@ -3942,7 +3960,9 @@ void GlobalObject::visitChildrenImpl(JSCell* cell, Visitor& visitor) thisObject->mockModule.mockResultStructure.visit(visitor); thisObject->mockModule.mockWithImplementationCleanupDataStructure.visit(visitor); thisObject->mockModule.withImplementationCleanupFunction.visit(visitor); - + thisObject->m_ServerRouteListStructure.visit(visitor); + thisObject->m_JSBunRequestStructure.visit(visitor); + thisObject->m_JSBunRequestParamsPrototype.visit(visitor); thisObject->m_JSX509CertificateClassStructure.visit(visitor); thisObject->m_nodeErrorCache.visit(visitor); diff --git a/src/bun.js/bindings/ZigGlobalObject.h b/src/bun.js/bindings/ZigGlobalObject.h index fc00caeb1f..45980c1a3b 100644 --- a/src/bun.js/bindings/ZigGlobalObject.h +++ b/src/bun.js/bindings/ZigGlobalObject.h @@ -604,6 +604,9 @@ public: LazyProperty m_performanceObject; LazyProperty m_processObject; LazyProperty m_lazyStackCustomGetterSetter; + LazyProperty m_ServerRouteListStructure; + LazyProperty m_JSBunRequestStructure; + LazyProperty m_JSBunRequestParamsPrototype; bool hasOverridenModuleResolveFilenameFunction = false; diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 2fbf08ef26..035b613d72 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -7047,3 +7047,5 @@ pub const DeferredError = struct { return err; } }; + +pub const JSSocketAddress = @import("./JSSocketAddress.zig").JSSocketAddress; diff --git a/src/bun.js/bindings/decodeURIComponentSIMD.cpp b/src/bun.js/bindings/decodeURIComponentSIMD.cpp new file mode 100644 index 0000000000..98f27e5bd1 --- /dev/null +++ b/src/bun.js/bindings/decodeURIComponentSIMD.cpp @@ -0,0 +1,315 @@ + +#include "root.h" + +#include +#include +#include +namespace Bun { +using namespace WTF; + +ALWAYS_INLINE static uint8_t hexToInt(uint8_t c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + return 255; // Invalid +} + +WTF::String decodeURIComponentSIMD(std::span input) +{ + ASSERT_WITH_MESSAGE(simdutf::validate_ascii(reinterpret_cast(input.data()), input.size()), "Input is not ASCII"); + + const std::span lchar = { reinterpret_cast(input.data()), input.size() }; + + // Fast path - check if there are any % characters at all + const uint8_t* cursor = reinterpret_cast(input.data()); + const uint8_t* end = cursor + input.size(); + + constexpr size_t stride = SIMD::stride; + constexpr UChar replacementChar = 0xFFFD; + + auto percentVector = SIMD::splat('%'); + + // Check 16 bytes at a time + for (; cursor + stride <= end; cursor += stride) { + auto chunk = SIMD::load(cursor); + if (SIMD::isNonZero(SIMD::equal(chunk, percentVector))) { + goto slow_path; + } + } + + // Check any remaining bytes + while (cursor < end) { + if (*cursor == '%') + goto slow_path; + cursor++; + } + + return String(lchar); + +slow_path: + StringBuilder result; + result.reserveCapacity(input.size()); + result.append(std::span(reinterpret_cast(input.data()), cursor - input.data())); + + while (cursor < end) { + if (*cursor == '%') { + if (cursor + 2 >= end) { + result.append(replacementChar); + cursor++; + continue; + } + + uint8_t highNibble = hexToInt(cursor[1]); + uint8_t lowNibble = hexToInt(cursor[2]); + + if (highNibble > 15 || lowNibble > 15) { + result.append(replacementChar); + cursor += (cursor + 2 < end) ? 3 : 1; + continue; + } + + uint8_t byte = (highNibble << 4) | lowNibble; + + // Start of UTF-8 sequence + if ((byte & 0x80) == 0) { + // ASCII + result.append(byte); + cursor += 3; + } else if ((byte & 0xE0) == 0xC0) { + // 2-byte sequence + uint32_t value = byte & 0x1F; + cursor += 3; + + // Get second byte + if (cursor + 2 >= end || *cursor != '%') { + result.append(replacementChar); + continue; + } + + highNibble = hexToInt(cursor[1]); + lowNibble = hexToInt(cursor[2]); + if (highNibble > 15 || lowNibble > 15) { + result.append(replacementChar); + continue; + } + byte = (highNibble << 4) | lowNibble; + if ((byte & 0xC0) != 0x80) { + result.append(replacementChar); + continue; + } + value = (value << 6) | (byte & 0x3F); + cursor += 3; + + // Check for overlong encoding + if (value < 0x80 || value > 0x7FF) { + result.append(replacementChar); + continue; + } + + result.append(static_cast(value)); + } else if ((byte & 0xF0) == 0xE0) { + // 3-byte sequence + uint32_t value = byte & 0x0F; + cursor += 3; + + // Get second byte + if (cursor + 2 >= end || *cursor != '%') { + result.append(replacementChar); + continue; + } + highNibble = hexToInt(cursor[1]); + lowNibble = hexToInt(cursor[2]); + if (highNibble > 15 || lowNibble > 15) { + result.append(replacementChar); + continue; + } + byte = (highNibble << 4) | lowNibble; + if ((byte & 0xC0) != 0x80) { + result.append(replacementChar); + continue; + } + value = (value << 6) | (byte & 0x3F); + cursor += 3; + + // Get third byte + if (cursor + 2 >= end || *cursor != '%') { + result.append(replacementChar); + continue; + } + highNibble = hexToInt(cursor[1]); + lowNibble = hexToInt(cursor[2]); + if (highNibble > 15 || lowNibble > 15) { + result.append(replacementChar); + continue; + } + byte = (highNibble << 4) | lowNibble; + if ((byte & 0xC0) != 0x80) { + result.append(replacementChar); + continue; + } + value = (value << 6) | (byte & 0x3F); + cursor += 3; + + // Check for overlong encoding and surrogate range + if (value < 0x800 || value > 0xFFFF || (value >= 0xD800 && value <= 0xDFFF) || // Surrogate range check + (byte == 0xE0 && (value & 0x1F00) == 0)) // Overlong check for E0 + { + result.append(replacementChar); + continue; + } + + result.append(static_cast(value)); + } else if ((byte & 0xF8) == 0xF0) { + // 4-byte sequence -> surrogate pair + uint32_t value = byte & 0x07; + cursor += 3; + + // Get second byte + if (cursor + 2 >= end || *cursor != '%') { + result.append(replacementChar); + continue; + } + highNibble = hexToInt(cursor[1]); + lowNibble = hexToInt(cursor[2]); + if (highNibble > 15 || lowNibble > 15) { + result.append(replacementChar); + continue; + } + byte = (highNibble << 4) | lowNibble; + if ((byte & 0xC0) != 0x80) { + result.append(replacementChar); + continue; + } + value = (value << 6) | (byte & 0x3F); + cursor += 3; + + // Get third byte + if (cursor + 2 >= end || *cursor != '%') { + result.append(replacementChar); + continue; + } + highNibble = hexToInt(cursor[1]); + lowNibble = hexToInt(cursor[2]); + if (highNibble > 15 || lowNibble > 15) { + result.append(replacementChar); + continue; + } + byte = (highNibble << 4) | lowNibble; + if ((byte & 0xC0) != 0x80) { + result.append(replacementChar); + continue; + } + value = (value << 6) | (byte & 0x3F); + cursor += 3; + + // Get fourth byte + if (cursor + 2 >= end || *cursor != '%') { + result.append(replacementChar); + continue; + } + highNibble = hexToInt(cursor[1]); + lowNibble = hexToInt(cursor[2]); + if (highNibble > 15 || lowNibble > 15) { + result.append(replacementChar); + continue; + } + byte = (highNibble << 4) | lowNibble; + if ((byte & 0xC0) != 0x80) { + result.append(replacementChar); + continue; + } + value = (value << 6) | (byte & 0x3F); + cursor += 3; + + // Check for overlong encoding and maximum valid code point + if (value < 0x10000 || value > 0x10FFFF || (byte == 0xF0 && (value & 0x040000) == 0) || // Overlong check for F0 + (byte == 0xF4 && value > 0x10FFFF)) // Max code point check + { + result.append(replacementChar); + continue; + } + + // Convert to surrogate pair + value -= 0x10000; + result.append(static_cast(0xD800 | (value >> 10))); + result.append(static_cast(0xDC00 | (value & 0x3FF))); + } else { + result.append(replacementChar); + cursor += (cursor + 2 < end) ? 3 : 1; + } + continue; + } else { + // Look ahead for next % using SIMD + const uint8_t* lookAhead = cursor; + while (lookAhead + 16 <= end) { + auto chunk = SIMD::load(lookAhead); + if (SIMD::isNonZero(SIMD::equal(chunk, percentVector))) { + break; + } + lookAhead += 16; + } + + // Append everything up to lookAhead + result.append(std::span(reinterpret_cast(cursor), lookAhead - cursor)); + cursor = lookAhead; + + // Handle remaining bytes until next % or end + while (cursor < end && *cursor != '%') { + cursor++; + } + if (cursor > lookAhead) { + result.append(std::span(reinterpret_cast(lookAhead), cursor - lookAhead)); + } + } + } + + return result.toString(); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionDecodeURIComponentSIMD, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSC::JSValue input = callFrame->argument(0); + if (input.isString()) { + auto string = input.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + if (!string.is8Bit()) { + const auto span = string.span16(); + size_t expected_length = simdutf::latin1_length_from_utf16(span.size()); + std::span ptr; + WTF::String convertedString = WTF::String::createUninitialized(expected_length, ptr); + if (UNLIKELY(convertedString.isNull())) { + throwVMError(globalObject, scope, createOutOfMemoryError(globalObject)); + return {}; + } + + auto result = simdutf::convert_utf16le_to_latin1_with_errors(span.data(), span.size(), reinterpret_cast(ptr.data())); + + if (result.error) { + scope.throwException(globalObject, createRangeError(globalObject, "Invalid character in input"_s)); + return {}; + } + string = convertedString; + } + + auto span = string.span8(); + auto&& output = decodeURIComponentSIMD(span); + return JSC::JSValue::encode(JSC::jsString(vm, output)); + } + + JSC::JSArrayBufferView* view = jsDynamicCast(input); + if (!view) { + return JSC::JSValue::encode(JSC::jsUndefined()); + } + auto span = view->span(); + auto&& output = decodeURIComponentSIMD(span); + return JSC::JSValue::encode(JSC::jsString(vm, output)); +} +} diff --git a/src/bun.js/bindings/decodeURIComponentSIMD.h b/src/bun.js/bindings/decodeURIComponentSIMD.h new file mode 100644 index 0000000000..7523013538 --- /dev/null +++ b/src/bun.js/bindings/decodeURIComponentSIMD.h @@ -0,0 +1,7 @@ +namespace Bun { + +// Expected to be ASCII input potentially encoded with %20, %21, etc. +WTF::String decodeURIComponentSIMD(std::span input); + +JSC_DECLARE_HOST_FUNCTION(jsFunctionDecodeURIComponentSIMD); +} diff --git a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h index e5d24cb951..436bbb7d1b 100644 --- a/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMClientIsoSubspaces.h @@ -920,5 +920,8 @@ public: std::unique_ptr m_clientSubspaceForEventListener; std::unique_ptr m_clientSubspaceForEventTarget; std::unique_ptr m_clientSubspaceForEventEmitter; + + std::unique_ptr m_clientSubspaceForServerRouteList; + std::unique_ptr m_clientSubspaceForBunRequest; }; } // namespace WebCore diff --git a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h index bc995592e7..ffc284dc9f 100644 --- a/src/bun.js/bindings/webcore/DOMIsoSubspaces.h +++ b/src/bun.js/bindings/webcore/DOMIsoSubspaces.h @@ -923,6 +923,8 @@ public: // std::unique_ptr m_subspaceForDOMFormData; // std::unique_ptr m_subspaceForDOMFormDataIterator; std::unique_ptr m_subspaceForDOMURL; + std::unique_ptr m_subspaceForServerRouteList; + std::unique_ptr m_subspaceForBunRequest; }; } // namespace WebCore diff --git a/src/bun.js/test/pretty_format.zig b/src/bun.js/test/pretty_format.zig index f5317d5d87..9006a8a6c8 100644 --- a/src/bun.js/test/pretty_format.zig +++ b/src/bun.js/test/pretty_format.zig @@ -1233,7 +1233,7 @@ pub const JestPrettyFormat = struct { return error.JSError; }; } else if (value.as(JSC.WebCore.Request)) |request| { - request.writeFormat(Formatter, this, writer_, enable_ansi_colors) catch |err| { + request.writeFormat(value, Formatter, this, writer_, enable_ansi_colors) catch |err| { this.failed = true; // TODO: make this better if (!this.globalThis.hasException()) { diff --git a/src/bun.js/webcore/request.zig b/src/bun.js/webcore/request.zig index 4d85d4226c..54605dd30e 100644 --- a/src/bun.js/webcore/request.zig +++ b/src/bun.js/webcore/request.zig @@ -192,18 +192,39 @@ pub const Request = struct { return this.reported_estimated_size; } + pub fn getRemoteSocketInfo(this: *Request, globalObject: *JSC.JSGlobalObject) ?JSC.JSValue { + if (this.request_context.getRemoteSocketInfo()) |info| { + return JSC.JSSocketAddress.create(globalObject, info.ip, info.port, info.is_ipv6); + } + + return null; + } + pub fn calculateEstimatedByteSize(this: *Request) void { this.reported_estimated_size = this.body.value.estimatedSize() + this.sizeOfURL() + @sizeOf(Request); } + pub export fn Bun__JSRequest__calculateEstimatedByteSize(this: *Request) void { + this.calculateEstimatedByteSize(); + } + pub fn toJS(this: *Request, globalObject: *JSGlobalObject) JSValue { this.calculateEstimatedByteSize(); return Request.toJSUnchecked(globalObject, this); } - pub fn writeFormat(this: *Request, comptime Formatter: type, formatter: *Formatter, writer: anytype, comptime enable_ansi_colors: bool) !void { + extern "JS" fn Bun__getParamsIfBunRequest(this_value: JSValue) JSValue; + + pub fn writeFormat(this: *Request, this_value: JSValue, comptime Formatter: type, formatter: *Formatter, writer: anytype, comptime enable_ansi_colors: bool) !void { const Writer = @TypeOf(writer); - try writer.print("Request ({}) {{\n", .{bun.fmt.size(this.body.value.size(), .{})}); + + const params_object = Bun__getParamsIfBunRequest(this_value); + + const class_label = switch (params_object) { + .zero => "Request", + else => "BunRequest", + }; + try writer.print("{s} ({}) {{\n", .{ class_label, bun.fmt.size(this.body.value.size(), .{}) }); { formatter.indent += 1; defer formatter.indent -|= 1; @@ -223,6 +244,14 @@ pub const Request = struct { formatter.printComma(Writer, writer, enable_ansi_colors) catch unreachable; try writer.writeAll("\n"); + if (params_object.isCell()) { + try formatter.writeIndent(Writer, writer); + try writer.writeAll(comptime Output.prettyFmt("params: ", enable_ansi_colors)); + try formatter.printAs(.Private, Writer, writer, params_object, .Object, enable_ansi_colors); + formatter.printComma(Writer, writer, enable_ansi_colors) catch unreachable; + try writer.writeAll("\n"); + } + try formatter.writeIndent(Writer, writer); try writer.writeAll(comptime Output.prettyFmt("headers: ", enable_ansi_colors)); try formatter.printAs(.Private, Writer, writer, this.getHeaders(formatter.globalThis), .DOMWrapper, enable_ansi_colors); diff --git a/src/bun.js/webcore/response.classes.ts b/src/bun.js/webcore/response.classes.ts index bbb6112950..27c26d0be6 100644 --- a/src/bun.js/webcore/response.classes.ts +++ b/src/bun.js/webcore/response.classes.ts @@ -5,6 +5,7 @@ export default [ name: "Request", construct: true, finalize: true, + final: false, klass: {}, JSType: "0b11101110", estimatedSize: true, diff --git a/src/deps/uws.zig b/src/deps/uws.zig index 3b048592cf..ba9318c9e9 100644 --- a/src/deps/uws.zig +++ b/src/deps/uws.zig @@ -3493,6 +3493,27 @@ pub fn NewApp(comptime ssl: bool) type { ) void { uws_app_trace(ssl_flag, @as(*uws_app_t, @ptrCast(app)), pattern, RouteHandler(UserDataType, handler).handle, if (UserDataType == void) null else user_data); } + pub fn method( + app: *ThisApp, + method_: bun.http.Method, + pattern: [:0]const u8, + comptime UserDataType: type, + user_data: UserDataType, + comptime handler: (fn (UserDataType, *Request, *Response) void), + ) void { + switch (method_) { + .GET => app.get(pattern, UserDataType, user_data, handler), + .POST => app.post(pattern, UserDataType, user_data, handler), + .PUT => app.put(pattern, UserDataType, user_data, handler), + .DELETE => app.delete(pattern, UserDataType, user_data, handler), + .PATCH => app.patch(pattern, UserDataType, user_data, handler), + .OPTIONS => app.options(pattern, UserDataType, user_data, handler), + .HEAD => app.head(pattern, UserDataType, user_data, handler), + .CONNECT => app.connect(pattern, UserDataType, user_data, handler), + .TRACE => app.trace(pattern, UserDataType, user_data, handler), + else => @panic("TODO: implement other methods"), + } + } pub fn any( app: *ThisApp, pattern: []const u8, diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 2b5ffd8e4c..9eaacee2f0 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -172,3 +172,9 @@ export const arrayBufferViewHasBuffer = $newCppFunction( "jsFunction_arrayBufferViewHasBuffer", 1, ); + +export const decodeURIComponentSIMD = $newCppFunction( + "decodeURIComponentSIMD.cpp", + "jsFunctionDecodeURIComponentSIMD", + 1, +); diff --git a/test/js/bun/http/bun-serve-routes.test.ts b/test/js/bun/http/bun-serve-routes.test.ts new file mode 100644 index 0000000000..23d3f4d2da --- /dev/null +++ b/test/js/bun/http/bun-serve-routes.test.ts @@ -0,0 +1,433 @@ +import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; +import { isBroken, isMacOS } from "harness"; +import type { Server, ServeOptions, BunRequest } from "bun"; + +describe("path parameters", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/users/:id": (req: BunRequest<"/users/:id">) => { + return new Response( + JSON.stringify({ + id: req.params.id, + method: req.method, + }), + ); + }, + "/posts/:postId/comments/:commentId": (req: BunRequest<"/posts/:postId/comments/:commentId">) => { + console.log(req.params); + return new Response(JSON.stringify(req.params)); + }, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("handles single parameter", async () => { + const res = await fetch(`${server.url}users/123`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + id: "123", + method: "GET", + }); + }); + + it("handles multiple parameters", async () => { + const res = await fetch(new URL(`/posts/456/comments/789`, server.url).href); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + postId: "456", + commentId: "789", + }); + }); + + it("handles encoded parameters", async () => { + const res = await fetch(new URL(`/users/user@example.com`, server.url).href); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + id: "user@example.com", + method: "GET", + }); + }); + + it("handles unicode parameters", async () => { + const res = await fetch(`${server.url}users/🦊`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + id: "🦊", + method: "GET", + }); + }); +}); + +describe("HTTP methods", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/api": { + GET: () => new Response("GET"), + POST: () => new Response("POST"), + PUT: () => new Response("PUT"), + DELETE: () => new Response("DELETE"), + PATCH: () => new Response("PATCH"), + OPTIONS: () => new Response("OPTIONS"), + HEAD: () => new Response("HEAD"), + }, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + test.each([["GET"], ["POST"], ["PUT"], ["DELETE"], ["PATCH"], ["OPTIONS"], ["HEAD"]])("%s request", async method => { + const res = await fetch(`${server.url}api`, { method }); + expect(res.status).toBe(200); + if (method === "HEAD") { + expect(await res.text()).toBe(""); + } else { + expect(await res.text()).toBe(method); + } + }); +}); + +describe("static responses", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/static": new Response("static response", { + headers: { "content-type": "text/plain" }, + }), + "/html": new Response("

Hello

", { + headers: { "content-type": "text/html" }, + }), + "/skip": false, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("serves static Response", async () => { + const res = await fetch(`${server.url}static`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/plain"); + expect(await res.text()).toBe("static response"); + }); + + it("serves HTML response", async () => { + const res = await fetch(`${server.url}html`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html"); + expect(await res.text()).toBe("

Hello

"); + }); + + it("skips route when false", async () => { + const res = await fetch(`${server.url}skip`); + expect(await res.text()).toBe("fallback"); + }); +}); + +describe("route precedence", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/api/users": () => new Response("users list"), + "/api/users/:id": (req: BunRequest<"/api/users/:id">) => new Response(`user ${req.params.id}`), + "/api/*": () => new Response("api catchall"), + "/api/users/:id/posts": (req: BunRequest<"/api/users/:id/posts">) => new Response(`posts for ${req.params.id}`), + "/*": () => new Response("root catchall"), + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("matches exact routes before parameters", async () => { + const res = await fetch(`${server.url}api/users`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("users list"); + }); + + it("matches parameterized routes before wildcards", async () => { + const res = await fetch(`${server.url}api/users/123`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("user 123"); + }); + + it("matches specific wildcards before root wildcard", async () => { + const res = await fetch(`${server.url}api/unknown`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("api catchall"); + }); + + it.todo("matches root wildcard as last resort", async () => { + const res = await fetch(`${server.url}unknown`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("root catchall"); + }); + + it("prefers earlier routes when patterns overlap", async () => { + const res = await fetch(`${server.url}api/users/123/posts`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("posts for 123"); + }); +}); + +describe("error handling", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/error": () => { + throw new Error("Intentional error"); + }, + "/async-error": async () => { + throw new Error("Async error"); + }, + }, + error(error) { + return new Response(`Error: ${error.message}`, { status: 500 }); + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("handles synchronous errors", async () => { + const res = await fetch(`${server.url}error`); + expect(res.status).toBe(500); + expect(await res.text()).toBe("Error: Intentional error"); + }); + + it("handles asynchronous errors", async () => { + const res = await fetch(`${server.url}async-error`); + expect(res.status).toBe(500); + expect(await res.text()).toBe("Error: Async error"); + }); +}); + +describe("request properties", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/echo-headers": req => new Response(JSON.stringify(Object.fromEntries(req.headers))), + "/echo-method": req => new Response(req.method), + "/echo-url": req => + new Response( + JSON.stringify({ + url: req.url, + pathname: new URL(req.url).pathname, + }), + ), + "/echo-body": async req => new Response(await req.text()), + "/echo-query": req => new Response(JSON.stringify(Object.fromEntries(new URL(req.url).searchParams))), + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("preserves request headers", async () => { + const res = await fetch(`${server.url}echo-headers`, { + headers: { + "x-test": "value", + "user-agent": "test-agent", + }, + }); + expect(res.status).toBe(200); + const headers = await res.json(); + expect(headers["x-test"]).toBe("value"); + expect(headers["user-agent"]).toBe("test-agent"); + }); + + it("preserves request method", async () => { + const res = await fetch(`${server.url}echo-method`, { method: "PATCH" }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("PATCH"); + }); + + it("provides correct URL properties", async () => { + const res = await fetch(`${server.url}echo-url?foo=bar`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.url).toInclude("echo-url?foo=bar"); + expect(data.pathname).toBe("/echo-url"); + }); + + it("handles request body", async () => { + const body = "test body content"; + const res = await fetch(`${server.url}echo-body`, { + method: "POST", + body, + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe(body); + }); + + it("preserves query parameters", async () => { + const res = await fetch(`${server.url}echo-query?foo=bar&baz=qux`); + expect(res.status).toBe(200); + const query = await res.json(); + expect(query).toEqual({ + foo: "bar", + baz: "qux", + }); + }); +}); + +describe("route reloading", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/test": () => new Response("original"), + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + it("updates routes on reload", async () => { + // Check original route + let res = await fetch(new URL(`/test`, server.url).href); + expect(await res.text()).toBe("original"); + + // Reload with new routes + server.reload({ + fetch: () => new Response("fallback"), + routes: { + "/test": () => new Response("updated"), + }, + } as ServeOptions); + + // Check updated route + res = await fetch(new URL(`/test`, server.url).href); + expect(await res.text()).toBe("updated"); + }); + + it("handles removing routes on reload", async () => { + // Reload with empty routes + server.reload({ + fetch: () => new Response("fallback"), + routes: {}, + } as ServeOptions); + + // Should fall back to fetch handler + const res = await fetch(`${server.url}test`); + expect(await res.text()).toBe("fallback"); + }); +}); + +describe("many route params", () => { + let server: Server; + + beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch: () => new Response("fallback"), + routes: { + "/test/:p1/:p2/:p3/:p4/:p5/:p6/:p7/:p8/:p9/:p10/:p11/:p12/:p13/:p14/:p15/:p16/:p17/:p18/:p19/:p20/:p21/:p22/:p23/:p24/:p25/:p26/:p27/:p28/:p29/:p30/:p31/:p32/:p33/:p34/:p35/:p36/:p37/:p38/:p39/:p40/:p41/:p42/:p43/:p44/:p45/:p46/:p47/:p48/:p49/:p50/:p51/:p52/:p53/:p54/:p55/:p56/:p57/:p58/:p59/:p60/:p61/:p62/:p63/:p64/:p65": + ( + req: BunRequest<"/test/:p1/:p2/:p3/:p4/:p5/:p6/:p7/:p8/:p9/:p10/:p11/:p12/:p13/:p14/:p15/:p16/:p17/:p18/:p19/:p20/:p21/:p22/:p23/:p24/:p25/:p26/:p27/:p28/:p29/:p30/:p31/:p32/:p33/:p34/:p35/:p36/:p37/:p38/:p39/:p40/:p41/:p42/:p43/:p44/:p45/:p46/:p47/:p48/:p49/:p50/:p51/:p52/:p53/:p54/:p55/:p56/:p57/:p58/:p59/:p60/:p61/:p62/:p63/:p64/:p65">, + ) => { + // @ts-expect-error + return new Response(JSON.stringify(req.params)); + }, + }, + }); + server.unref(); + }); + + afterAll(() => { + server.stop(true); + }); + + // JSFinalObject::maxInlineCapacity + it("handles 65 route parameters", async () => { + const values = Array.from({ length: 65 }, (_, i) => `value${i + 1}`); + const path = `/test/${values.join("/")}`; + const res = await fetch(new URL(path, server.url).href); + expect(res.status).toBe(200); + + const params = await res.json(); + expect(Object.keys(params)).toHaveLength(65); + + for (let i = 1; i <= 65; i++) { + expect(params[`p${i}`]).toBe(`value${i}`); + } + }); +}); + +it("throws a validation error when a route parameter name starts with a number", () => { + expect(() => { + Bun.serve({ + routes: { "/test/:123": () => new Response("test") }, + fetch(req) { + return new Response("test"); + }, + }); + }).toThrow("Route parameter names cannot start with a number."); +}); + +it("throws a validation error when a route parameter name is duplicated", () => { + expect(() => { + Bun.serve({ + routes: { "/test/:a123/:a123": () => new Response("test") }, + fetch(req) { + return new Response("test"); + }, + }); + }).toThrow("Support for duplicate route parameter names is not yet implemented."); +}); diff --git a/test/js/bun/http/decodeURIComponentSIMD.test.ts b/test/js/bun/http/decodeURIComponentSIMD.test.ts new file mode 100644 index 0000000000..279b1e2477 --- /dev/null +++ b/test/js/bun/http/decodeURIComponentSIMD.test.ts @@ -0,0 +1,372 @@ +import { decodeURIComponentSIMD } from "bun:internal-for-testing"; +import { expect, test, describe, it } from "bun:test"; + +const inputs = [ + "hello world", + "hello world ", + " hello world", + "!@#$%^&*()", + "1234567890", + "abcdefghijklmnopqrstuvwxyz", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "こんにけは", + "δ½ ε₯½", + "μ•ˆλ…•ν•˜μ„Έμš”", + "Ω…Ψ±Ψ­Ψ¨Ψ§", + "Χ©ΦΈΧΧœΧ•ΦΉΧ", + "🌍🌎🌏", + "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", + "πŸ‡ΊπŸ‡ΈπŸ‡―πŸ‡΅πŸ‡°πŸ‡·", + "https://example.com/path?param=value", + "user@example.com", + "path/to/file.txt", + "C:\\Windows\\System32", + "", + "SELECT * FROM users;", + "{}[]|\\", + " ", + "", + "a".repeat(1000), + "🌟".repeat(100), + "hello\nworld", + "hello\tworld", + "hello\rworld", + "hello\\world", + 'hello"world', + "hello'world", + "hello`world", + "hello/world", + "hello?world", + "hello=world", + "hello&world", + "hello+world", + "hello%20world", + "hello%2Fworld", + "hello%3Fworld", + "hello%3Dworld", + "hello%26world", + "hello%2Bworld", + "hello%25world", + "hello%23world", + "hello%40world", + "hello%21world", + "hello%24world", + "hello%2Cworld", + "hello%3Bworld", + "hello%3Aworld", + "hello%5Bworld", + "hello%5Dworld", + "hello%7Bworld", + "hello%7Dworld", + "hello%7Cworld", + "hello%5Cworld", + "hello%22world", + "hello%27world", + "hello%60world", + "hello%3Cworld", + "hello%3Eworld", + "hello%2Eworld", + "hello%2Dworld", + "hello%5Fworld", + "hello%7Eworld", + "hello%2Aworld", + "hello%2Bworld", + "hello%2Cworld", + "hello%2Fworld", + "hello%3Aworld", + "hello%3Bworld", + "hello%3Cworld", + "hello%3Dworld", + "hello%3Eworld", + "hello%3Fworld", + "hello%40world", + "hello%5Bworld", + "hello%5Cworld", + "hello%5Dworld", + "hello%5Eworld", + "hello%5Fworld", + "hello%60world", + "hello%7Bworld", + "hello%7Cworld", + "hello%7Dworld", + "hello%7Eworld", + "hello%7Fworld", + "hello%80world", + "hello%FFworld", + "hello%F0%9F%8C%9F", + "hello%F0%9F%98%80", + "hello%F0%9F%98%81", + "hello%F0%9F%98%82", + "hello%F0%9F%98%83", + "hello%F0%9F%98%84", + "hello%F0%9F%98%85", + "hello%F0%9F%98%86", + "hello%F0%9F%98%87", + "hello%F0%9F%98%88", + "hello%F0%9F%98%89", + "hello%F0%9F%98%8A", + "hello%F0%9F%98%8B", + "hello%F0%9F%98%8C", + "hello%F0%9F%98%8D", + "hello%F0%9F%98%8E", + "hello%F0%9F%98%8F", + "hello%F0%9F%98%90", + "hello%F0%9F%98%91", + // Test 16-byte boundary cases + "1234567890123456%20", // % at byte 16 + "123456789012345%20a", // % at byte 15 + "12345678901234%20ab", // % at byte 14 + "1234567890123%20abc", // % at byte 13 + "123456789012%20abcd", // % at byte 12 + "12345678901%20abcde", // % at byte 11 + "1234567890%20abcdef", // % at byte 10 + "123456789%20abcdefg", // % at byte 9 + "12345678%20abcdefgh", // % at byte 8 + "1234567%20abcdefghi", // % at byte 7 + "123456%20abcdefghij", // % at byte 6 + "12345%20abcdefghijk", // % at byte 5 + "1234%20abcdefghijkl", // % at byte 4 + "123%20abcdefghijklm", // % at byte 3 + "12%20abcdefghijklmn", // % at byte 2 + "1%20abcdefghijklmno", // % at byte 1 + "%20abcdefghijklmnop", // % at byte 0 + "1234567890123456%20abcd", // Multiple of 16 before % + "12345678901234567890%20", // Multiple of 16 + 4 before % + "123456789012345678901234567890%20", // Multiple of 16 + 14 before % + + // Additional boundary tests with different encoded characters + "1234567890123456%2B", // + at boundary + "1234567890123456%3D", // = at boundary + "1234567890123456%2F", // / at boundary + "1234567890123456%3F", // ? at boundary + "1234567890123456%26", // & at boundary + + // Multiple percent encodings near boundaries + "12345678901234%20%20", // Two spaces at boundary + "1234567890123%20%20a", // Two spaces near boundary + "123456789012%20%20ab", // Two spaces near boundary + + // UTF-8 multi-byte sequences at boundaries + "1234567890123456%F0%9F%98%80", // Emoji at boundary + "12345678901234%F0%9F%98%80ab", // Emoji near boundary + "123456789012%F0%9F%98%80abcd", // Emoji near boundary + + // Mixed ASCII and encoded characters + "1234567890123456%20ABC%20", + "1234567890123456%20%F0%9F%98%80", + "12345678901234%20%F0%9F%98%80ab", + + // Multiple boundaries in sequence + "1234567890123456%201234567890123456%20", + "1234567890123456%201234567890123456%2B", + "1234567890123456%201234567890123456%3D", + + // Testing with different encoded characters at boundaries + "1234567890123456%251234567890123456%24", + "1234567890123456%261234567890123456%23", + "1234567890123456%271234567890123456%22", + + // Testing with invalid sequences at boundaries + "1234567890123456%", // Incomplete percent encoding at boundary + "1234567890123456%2", // Incomplete percent encoding at boundary + "1234567890123456%G0", // Invalid hex digit at boundary + + // Testing with multiple encodings in quick succession + "12345678901234%20%20%20%20", + "1234567890123%20%20%20%20a", + "123456789012%20%20%20%20ab", + + // Testing with mixed valid and invalid sequences + "1234567890123456%20%GG%20", + "1234567890123456%20%%20", + "1234567890123456%20%2%20", + + // Testing boundaries with special characters + "1234567890123456%0A", // newline + "1234567890123456%0D", // carriage return + "1234567890123456%09", // tab + + // Testing with URL-specific characters + "1234567890123456%3A%2F%2F", // :// + "1234567890123456%3F%3D%26", // ?=& + "1234567890123456%23%40%21", // #@! + + // Testing with multiple boundaries and mixed content + "1234567890123456%201234567890123456%F0%9F%98%80", + "1234567890123456%2B1234567890123456%20%F0%9F%98%80", + "1234567890123456%3D1234567890123456%20ABC%20", + + // Edge cases with repeated patterns + "1234567890123456%20%20%20%201234567890123456%20%20%20%20", + "1234567890123456%25%25%25%251234567890123456%25%25%25%25", + "1234567890123456%2B%2B%2B%2B1234567890123456%2B%2B%2B%2B", +]; + +// Additional test cases for production quality URI component decoder +const additionalInputs = [ + // 1. Invalid UTF-8 Sequences + + // Incomplete UTF-8 sequences + "%E2%82", // Incomplete euro symbol + "%F0%90", // Incomplete 4-byte sequence + "%C2", // Incomplete 2-byte sequence + + // Overlong encodings + "%C0%AF", // Overlong '/' (should be %2F) + "%E0%80%AF", // Overlong '/' (3-byte) + "%F0%80%80%AF", // Overlong '/' (4-byte) + + // Invalid UTF-8 continuation bytes + "%C2%C0", // Invalid continuation + "%E2%82%C0", // Invalid continuation in 3-byte sequence + "%F0%90%80%C0", // Invalid continuation in 4-byte sequence + + // UTF-16 surrogate halves encoded in UTF-8 + "%ED%A0%80", // Lead surrogate U+D800 + "%ED%BE%80", // Trail surrogate U+DFFF + "%ED%A0%80%ED%B0%80", // Surrogate pair encoded in UTF-8 + + // 2. Memory and Buffer Edge Cases + + // SIMD boundary alignment + "a".repeat(15) + "%20", // 15 chars + encoded char + "a".repeat(16) + "%20", // 16 chars + encoded char + "a".repeat(31) + "%20", // 31 chars + encoded char + "a".repeat(32) + "%20", // 32 chars + encoded char + + // Large strings + "a".repeat(1024) + "%20" + "b".repeat(1024), + "%20".repeat(1000), // Many encoded characters + ("a".repeat(15) + "%20").repeat(100), // Repeating pattern at SIMD boundary + + // StringBuilder reallocation + "%F0%9F%98%80".repeat(1000), // Many emoji forcing StringBuilder growth + + // 3. Malformed Percent Encodings + + // Missing digits + "%", + "%%", + "%2", + "hello%", + "hello%2", + + // Invalid hex digits + "%0G", + "%G0", + "%GG", + "%00%0G", + + // Mixed case hex digits + "%2f", + "%2F", + "%2a", + "%2A", + + // Multiple % characters + "%%%", + "%%%%", + "%2%3", + "%25%25", + + // 4. Special Cases + + // Mixed valid and invalid sequences + "valid%20invalid%GGvalid%20", + "%20%FF%20", + + // Boundary conditions with valid/invalid sequences + "a".repeat(15) + "%GG", + "a".repeat(16) + "%GG", + "a".repeat(31) + "%GG", + + // Edge cases around StringBuilder capacity + ("valid%20" + "a".repeat(60)).repeat(100), + + // UTF-8 edge cases + "%F4%8F%BF%BF", // U+10FFFF (highest valid codepoint) + "%F4%90%80%80", // Above U+10FFFF (invalid) + + // Complex mixed scenarios + "hello%20%E2%82%AC%F0%9F%98%80world", // ASCII + space + euro + emoji + "%E2%82%AC".repeat(100) + "%F0%9F%98%80".repeat(100), // Alternating 3-byte and 4-byte sequences +]; + +describe("decodeURIComponentSIMD", () => { + for (const input of inputs) { + it(`should decode ${input}`, () => { + const encoded = encodeURIComponent(input); + const decoded = decodeURIComponentSIMD(encoded); + expect(decoded).toBe(decodeURIComponent(encoded)); + }); + } +}); + +describe("decodeURIComponentSIMD - Additional Tests", () => { + // Test error handling + for (const input of additionalInputs) { + it(`should handle ${input} without crashing`, () => { + try { + const decoded = decodeURIComponentSIMD(input); + // Some inputs are invalid, but shouldn't crash + if (decoded !== undefined) { + // For valid inputs, compare with native implementation + try { + const expected = decodeURIComponent(input); + expect(decoded).toBe(expected); + } catch (e) { + // Native implementation threw, our implementation should too + expect(() => decodeURIComponentSIMD(input)).toThrow(); + } + } + } catch (e) { + // If it throws, make sure native implementation also throws + expect(() => decodeURIComponent(input)).toThrow(); + } + }); + } +}); + +describe("decodeURIComponentSIMD edge cases", () => { + it("should handle cursor advancement correctly with invalid hex", () => { + // This test would fail because of the cursor advancement bug + // When it sees %GG, it only advances by 1 instead of 3, causing + // the GG to be treated as literal characters + expect(decodeURIComponentSIMD("%GG%20test")).toBe(String.fromCodePoint(0xfffd) + " " + "test"); + }); + + it("should handle multiple invalid sequences consecutively", () => { + // Similar cursor advancement issue + expect(decodeURIComponentSIMD("%ZZ%XX%YY")).toBe(String.fromCodePoint(0xfffd).repeat(3)); + }); + + it("should handle incomplete sequences at SIMD boundaries", () => { + // Create a string that puts a % character right at the SIMD boundary + // then follow it with invalid hex digits + const prefix = "a".repeat(15); // 15 bytes to align the % at boundary + expect(decodeURIComponentSIMD(prefix + "%GG")).toBe(prefix + String.fromCodePoint(0xfffd)); + }); + + it("should handle mixed valid/invalid sequences at SIMD boundaries", () => { + // This combines SIMD boundary alignment with the cursor advancement bug + const prefix = "a".repeat(15); + expect(decodeURIComponentSIMD(prefix + "%GG%20%HH%20")).toBe( + prefix + String.fromCodePoint(0xfffd) + " " + String.fromCodePoint(0xfffd) + " ", + ); + }); + + it("should handle large sequences of invalid encodings", () => { + // This would really expose the cursor advancement issue + const input = "%GG".repeat(1000); + // it should be full of unicode replacement characters + expect(decodeURIComponentSIMD(input).length).toBe(String.fromCodePoint(0xfffd).repeat(1000).length); + }); + + it("should handle invalid sequences followed by valid UTF-8", () => { + // This combines the cursor advancement bug with UTF-8 decoding + expect(decodeURIComponentSIMD("%GG%F0%9F%98%80")).toBe( + // replacement + replacement + smiley + String.fromCodePoint(0xfffd) + "πŸ˜€", + ); + }); +});