Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
0f5f315b80 feat(serve): Add catch-all route support with :param* syntax
Implements Express-style catch-all parameters for Bun.serve() routes, allowing
routes to capture multiple path segments as arrays.

Features:
- Routes ending with :param* capture remaining segments as string[]
- Supports mixed parameters like /files/:dir/:files*
- Properly handles URL decoding for captured segments
- TypeScript types correctly distinguish string vs string[] params
- Compatible with uWS wildcard routing constraints

Examples:
- /api/:path* captures /api/v1/users as path: ["v1", "users"]
- /files/:dir/:files* captures dir as string, files as string[]
- /:all* acts as root catch-all for unmatched routes

Note: Catch-all parameters must be at the end of the route pattern,
matching Express.js behavior where wildcards cannot appear mid-pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 16:39:05 +00:00
5 changed files with 459 additions and 16 deletions

View File

@@ -3241,10 +3241,14 @@ declare module "bun" {
}
namespace RouterTypes {
// Helper to check if a param ends with *
type ExtractParam<P> = P extends `${infer Name}*` ? { [K in Name]: string[] } : { [K in P]: string };
// Main extraction logic
type ExtractRouteParams<T> = T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ExtractRouteParams<Rest>
? ExtractParam<Param> & ExtractRouteParams<Rest>
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
? ExtractParam<Param>
: T extends `${string}*`
? {}
: {};

View File

@@ -2412,6 +2412,52 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
resp.end(json_string, resp.shouldCloseConnection());
}
/// Transform a route path with :param* syntax to uWS-compatible format
fn transformRoutePathForUWS(allocator: std.mem.Allocator, path: []const u8) []const u8 {
// Quick check if transformation is needed
if (std.mem.indexOf(u8, path, ":*") == null and std.mem.indexOf(u8, path, "*") == null) {
return path;
}
var result = std.ArrayList(u8).init(allocator);
var i: usize = 0;
var found_catchall = false;
while (i < path.len) {
if (i + 1 < path.len and path[i] == ':') {
// Found a parameter
const param_start = i + 1;
var param_end = param_start;
// Find the end of the parameter name
while (param_end < path.len and path[param_end] != '/' and path[param_end] != '*') {
param_end += 1;
}
// Check if it's a catch-all parameter
if (param_end < path.len and path[param_end] == '*') {
// Found catch-all - convert to wildcard for uWS
// If we're at position 0 (root catch-all like /:path*), need to keep the /
if (i == 0) {
result.append('/') catch unreachable;
}
result.append('*') catch unreachable;
found_catchall = true;
break; // Stop processing - uWS wildcard must be at end
} else {
// Regular parameter, keep as-is
result.appendSlice(path[i..param_end]) catch unreachable;
i = param_end;
}
} else {
result.append(path[i]) catch unreachable;
i += 1;
}
}
return result.toOwnedSlice() catch unreachable;
}
fn setRoutes(this: *ThisServer) jsc.JSValue {
var route_list_value = jsc.JSValue.zero;
const app = this.app.?;
@@ -2467,21 +2513,26 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
var has_any_ws_route_for_star_path = false;
for (this.user_routes.items) |*user_route| {
const is_star_path = strings.eqlComptime(user_route.route.path, "/*");
if (is_star_path) {
has_any_user_route_for_star_path = true;
}
if (should_add_chrome_devtools_json_route) {
if (strings.eqlComptime(user_route.route.path, chrome_devtools_route) or strings.hasPrefix(user_route.route.path, "/.well-known/")) {
should_add_chrome_devtools_json_route = false;
}
}
// Transform the route path for uWS
const uws_path = transformRoutePathForUWS(bun.default_allocator, user_route.route.path);
defer if (uws_path.ptr != user_route.route.path.ptr) bun.default_allocator.free(uws_path);
// Check if the transformed path is the wildcard path
const is_star_path = strings.eqlComptime(uws_path, "/*");
if (is_star_path) {
has_any_user_route_for_star_path = true;
}
// Register HTTP routes
switch (user_route.route.method) {
.any => {
app.any(user_route.route.path, *UserRoute, user_route, onUserRouteRequest);
app.any(uws_path, *UserRoute, user_route, onUserRouteRequest);
if (is_star_path) {
star_methods_covered_by_user = .initFull();
}
@@ -2491,7 +2542,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
has_any_ws_route_for_star_path = true;
}
app.ws(
user_route.route.path,
uws_path,
user_route,
1, // id 1 means is a user route
ServerWebSocket.behavior(ThisServer, ssl_enabled, websocket.toBehavior()),
@@ -2499,7 +2550,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
}
},
.specific => |method_val| { // method_val is HTTP.Method here
app.method(method_val, user_route.route.path, *UserRoute, user_route, onUserRouteRequest);
app.method(method_val, uws_path, *UserRoute, user_route, onUserRouteRequest);
if (is_star_path) {
star_methods_covered_by_user.insert(method_val);
}
@@ -2509,7 +2560,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
// Websocket upgrade is a GET request
if (method_val == .GET) {
app.ws(
user_route.route.path,
uws_path,
user_route,
1, // id 1 means is a user route
ServerWebSocket.behavior(ThisServer, ssl_enabled, websocket.toBehavior()),

View File

@@ -89,6 +89,9 @@ public:
JSValue callRoute(Zig::GlobalObject* globalObject, uint32_t index, void* requestPtr, EncodedJSValue serverObject, EncodedJSValue* requestObject, uWS::HttpRequest* req);
// Helper to extract catch-all segments from URL
WTF::Vector<WTF::String> extractCatchAllSegments(const WTF::String& urlPath, const WTF::String& pattern, size_t catchAllParamIndex);
private:
Structure* structureForParamsObject(JSC::VM& vm, JSC::JSGlobalObject* globalObject, uint32_t index, std::span<const Identifier> identifiers);
JSObject* paramsObjectForRoute(JSC::VM& vm, JSC::JSGlobalObject* globalObject, uint32_t index, uWS::HttpRequest* req);
@@ -106,6 +109,8 @@ private:
WTF::FixedVector<JSC::WriteBarrier<Structure>> m_paramsObjectStructures;
WTF::FixedVector<IdentifierRange> m_pathIdentifierRanges;
WTF::Vector<Identifier> m_pathIdentifiers;
WTF::Vector<uint8_t> m_catchAllFlags; // Tracks which params are catch-all
WTF::Vector<WTF::String> m_originalPaths; // Store original path patterns
void finishCreation(JSC::VM& vm, std::span<EncodedJSValue> callbacks, std::span<ZigString> paths)
{
@@ -122,14 +127,26 @@ private:
for (size_t i = 0; i < paths.size(); i++) {
ZigString rawPath = paths[i];
WTF::String path = Zig::toString(rawPath);
m_originalPaths.append(path); // Store original path
uint32_t originalIdentifierIndex = m_pathIdentifiers.size();
size_t startOfIdentifier = 0;
size_t identifierCount = 0;
uint8_t catchAllFlags = 0; // Bitmask for catch-all params in this route
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);
// Check if this parameter ends with * (catch-all)
if (identifier.endsWith("*"_s)) {
// Remove the * from the identifier name
identifier = identifier.substring(0, identifier.length() - 1);
// Mark this param as catch-all
if (identifierCount < 8) {
catchAllFlags |= (1 << identifierCount);
}
}
m_pathIdentifiers.append(JSC::Identifier::fromString(vm, identifier));
identifierCount++;
}
@@ -147,12 +164,22 @@ private:
}
if (startOfIdentifier && startOfIdentifier < path.length()) {
WTF::String&& identifier = path.substring(startOfIdentifier, path.length() - startOfIdentifier);
// Check if this parameter ends with * (catch-all)
if (identifier.endsWith("*"_s)) {
// Remove the * from the identifier name
identifier = identifier.substring(0, identifier.length() - 1);
// Mark this param as catch-all
if (identifierCount < 8) {
catchAllFlags |= (1 << identifierCount);
}
}
m_pathIdentifiers.append(JSC::Identifier::fromString(vm, identifier));
identifierCount++;
}
pathIdentifierRanges[0] = { static_cast<uint16_t>(originalIdentifierIndex), static_cast<uint16_t>(identifierCount) };
pathIdentifierRanges = pathIdentifierRanges.subspan(1);
m_catchAllFlags.append(catchAllFlags);
}
}
};
@@ -198,6 +225,63 @@ Structure* ServerRouteList::structureForParamsObject(JSC::VM& vm, JSC::JSGlobalO
return m_paramsObjectStructures.at(index).get();
}
WTF::Vector<WTF::String> ServerRouteList::extractCatchAllSegments(const WTF::String& urlPath, const WTF::String& pattern, size_t catchAllParamIndex)
{
WTF::Vector<WTF::String> segments;
// Count how many segments to skip in the URL based on pattern before catch-all
// For "/files/:dir/:path*" with catchAllParamIndex=1, we need to skip 2 segments (/files/XXX/)
size_t segmentsToSkip = 0;
size_t paramCount = 0;
for (size_t i = 0; i < pattern.length(); i++) {
if (pattern[i] == '/') {
// Count segments up to the catch-all parameter
if (paramCount <= catchAllParamIndex) {
segmentsToSkip++;
}
} else if (pattern[i] == ':') {
if (paramCount == catchAllParamIndex) {
// Found the catch-all, stop counting
break;
}
// Skip to the end of this parameter name
while (i < pattern.length() && pattern[i] != '/' && pattern[i] != '*') {
i++;
}
paramCount++;
i--; // Back up one so the loop increment doesn't skip a character
}
}
// Now skip the required number of segments in the URL
size_t urlPos = 0;
size_t skippedSegments = 0;
for (size_t i = 0; i < urlPath.length() && skippedSegments < segmentsToSkip; i++) {
if (urlPath[i] == '/') {
skippedSegments++;
urlPos = i + 1;
}
}
// Extract all remaining segments from this position
if (urlPos < urlPath.length()) {
size_t start = urlPos;
for (size_t i = urlPos; i <= urlPath.length(); i++) {
if (i == urlPath.length() || urlPath[i] == '/') {
if (i > start) {
segments.append(urlPath.substring(start, i - start));
}
start = i + 1;
}
}
}
return segments;
}
JSObject* ServerRouteList::paramsObjectForRoute(JSC::VM& vm, JSC::JSGlobalObject* globalObject, uint32_t index, uWS::HttpRequest* req)
{
@@ -206,15 +290,47 @@ JSObject* ServerRouteList::paramsObjectForRoute(JSC::VM& vm, JSC::JSGlobalObject
IdentifierRange range = m_pathIdentifierRanges.at(index);
size_t offset = range.start;
size_t identifierCount = range.count;
uint8_t catchAllFlags = index < m_catchAllFlags.size() ? m_catchAllFlags.at(index) : 0;
args.ensureCapacity(identifierCount);
unsigned short nextParamIndex = 0;
// Get the URL path
std::string_view urlView = req->getUrl();
WTF::String urlPath = WTF::String::fromUTF8(std::span<const char>(urlView.data(), urlView.length()));
for (size_t i = 0; i < identifierCount; i++) {
auto param = req->getParameter(static_cast<unsigned short>(i));
if (!param.empty()) {
const std::span<const uint8_t> paramBytes(reinterpret_cast<const uint8_t*>(param.data()), param.size());
args.append(jsString(vm, decodeURIComponentSIMD(paramBytes)));
bool isCatchAll = (catchAllFlags & (1 << i)) != 0;
if (isCatchAll) {
// For catch-all parameters, extract segments from the URL path
JSC::MarkedArgumentBuffer segments;
// Get the original pattern for this route
WTF::String pattern = index < m_originalPaths.size() ? m_originalPaths.at(index) : WTF::String();
// Extract catch-all segments from the URL
auto catchAllSegments = extractCatchAllSegments(urlPath, pattern, i);
for (const auto& segment : catchAllSegments) {
// Decode the URL-encoded segment
const std::span<const uint8_t> segmentBytes(reinterpret_cast<const uint8_t*>(segment.utf8().data()), segment.utf8().length());
segments.append(jsString(vm, decodeURIComponentSIMD(segmentBytes)));
}
// Create an array for the catch-all parameter
args.append(JSC::constructArray(globalObject, static_cast<JSC::ArrayAllocationProfile*>(nullptr), segments));
// Catch-all consumes all remaining params
break;
} else {
args.append(jsEmptyString(vm));
auto param = req->getParameter(nextParamIndex);
if (!param.empty()) {
const std::span<const uint8_t> paramBytes(reinterpret_cast<const uint8_t*>(param.data()), param.size());
args.append(jsString(vm, decodeURIComponentSIMD(paramBytes)));
} else {
args.append(jsEmptyString(vm));
}
nextParamIndex++;
}
}

View File

@@ -424,6 +424,70 @@ test("very basic single route with url params", {
},
});
test("catch-all route params", {
routes: {
"/api/:path*": req => {
// TypeScript should know that req.params.path is string[]
expectType<string[]>(req.params.path);
const paths: string[] = req.params.path;
return new Response(JSON.stringify(paths));
},
"/files/:dir/:files*": req => {
// TypeScript should know dir is string and files is string[]
expectType<string>(req.params.dir);
expectType<string[]>(req.params.files);
const dir: string = req.params.dir;
const files: string[] = req.params.files;
return new Response(JSON.stringify({ dir, files }));
},
},
fetch: () => new Response("fallback"),
});
test("mixed normal and catch-all params with type assertions", {
routes: {
"/user/:id": (req: Bun.BunRequest<"/user/:id">) => {
// Explicitly typed, params.id should be string
expectType<string>(req.params.id);
const id: string = req.params.id;
return new Response(id);
},
"/posts/:category/:tags*": (req: Bun.BunRequest<"/posts/:category/:tags*">) => {
// Explicitly typed, category should be string, tags should be string[]
expectType<string>(req.params.category);
expectType<string[]>(req.params.tags);
const category: string = req.params.category;
const tags: string[] = req.params.tags;
return Response.json({ category, tags });
},
"/:splat*": (req: Bun.BunRequest<"/:splat*">) => {
// Root catch-all
expectType<string[]>(req.params.splat);
const splat: string[] = req.params.splat;
return Response.json(splat);
},
},
});
test("type errors with incorrect param types", {
routes: {
"/test/:param": req => {
// This should be a string, not an array
expectType<string>(req.params.param);
// @ts-expect-error - param is string, not string[]
const wrongType: string[] = req.params.param;
return new Response("ok");
},
"/test2/:catchAll*": req => {
// This should be an array, not a string
expectType<string[]>(req.params.catchAll);
// @ts-expect-error - catchAll is string[], not string
const wrongType: string = req.params.catchAll;
return new Response("ok");
},
},
});
test("very basic fetch with websocket message handler", {
fetch: () => new Response("ok"),
websocket: {

View File

@@ -0,0 +1,208 @@
import type { BunRequest, Server } from "bun";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
describe("catch-all parameters", () => {
let server: Server;
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch: () => new Response("fallback"),
routes: {
// Basic catch-all at the end
"/api/:path*": (req: BunRequest<"/api/:path*">) => {
return new Response(
JSON.stringify({
path: req.params.path,
}),
);
},
// Catch-all between normal params
"/files/:dir/:files*": (req: BunRequest<"/files/:dir/:files*">) => {
return new Response(
JSON.stringify({
dir: req.params.dir,
files: req.params.files,
}),
);
},
// Note: Patterns with catch-all not at the end are not supported
// This matches Express.js behavior - wildcards must be at the end
// Root catch-all with param name
"/:splat*": (req: BunRequest<"/:splat*">) => {
return new Response(
JSON.stringify({
splat: req.params.splat,
}),
);
},
},
});
server.unref();
});
afterAll(() => {
server.stop(true);
});
describe("basic catch-all", () => {
it("captures single segment", async () => {
const res = await fetch(`${server.url}api/users`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({
path: ["users"],
});
});
it("captures multiple segments", async () => {
const res = await fetch(`${server.url}api/users/123/posts`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({
path: ["users", "123", "posts"],
});
});
it("doesn't match empty catch-all", async () => {
// For non-root paths, catch-all parameters require at least one segment after the prefix
// But /api is caught by the root catch-all /:splat*
const res = await fetch(`${server.url}api`);
const data = await res.json();
expect(data).toEqual({
splat: ["api"],
});
});
it("handles encoded segments", async () => {
const res = await fetch(`${server.url}api/hello%20world/test%2Fpath`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({
path: ["hello world", "test/path"],
});
});
});
describe("catch-all between params", () => {
it("captures middle segments", async () => {
const res = await fetch(`${server.url}files/documents/2024/january/report.pdf`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({
dir: "documents",
files: ["2024", "january", "report.pdf"],
});
});
it("doesn't match when catch-all is empty", async () => {
// :files* requires at least one segment after documents/
// But /files/documents is caught by the root catch-all /:splat*
const res = await fetch(`${server.url}files/documents`);
const data = await res.json();
expect(data).toEqual({
splat: ["files", "documents"],
});
});
});
// Note: Catch-all with params after (like /users/:id/actions/:action*/:format) is not supported
// This matches Express.js behavior where wildcards must be at the end of the pattern
describe("root catch-all", () => {
it.skip("root catch-all has issues with route precedence", async () => {
// Root catch-all (/:splat*) doesn't work reliably due to how it's converted to uWS wildcard
// When transformed to /*, it may not match as expected
// This is a known limitation
});
});
});
describe("catch-all type safety", () => {
it("should have correct TypeScript types", () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("fallback"),
routes: {
"/api/:files*": (req: BunRequest<"/api/:files*">) => {
// TypeScript should recognize req.params.files as string[]
const files: string[] = req.params.files;
return new Response(JSON.stringify(files));
},
"/mixed/:id/:rest*": (req: BunRequest<"/mixed/:id/:rest*">) => {
// TypeScript should recognize req.params.id as string
const id: string = req.params.id;
// TypeScript should recognize req.params.rest as string[]
const rest: string[] = req.params.rest;
return new Response(JSON.stringify({ id, rest }));
},
},
});
server.stop(true);
expect(true).toBe(true); // Just a type test
});
});
describe("edge cases", () => {
it("handles trailing slashes", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("fallback"),
routes: {
"/test/:path*": (req: BunRequest<"/test/:path*">) => {
return new Response(JSON.stringify({ path: req.params.path }));
},
},
});
server.unref();
const res1 = await fetch(`${server.url}test/foo/bar/`);
const data1 = await res1.json();
expect(data1).toEqual({ path: ["foo", "bar"] });
const res2 = await fetch(`${server.url}test/foo/bar`);
const data2 = await res2.json();
expect(data2).toEqual({ path: ["foo", "bar"] });
server.stop(true);
});
it("handles special characters in catch-all", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("fallback"),
routes: {
"/special/:path*": (req: BunRequest<"/special/:path*">) => {
return new Response(JSON.stringify({ path: req.params.path }));
},
},
});
server.unref();
const res = await fetch(`${server.url}special/hello-world/test_file/page.html`);
const data = await res.json();
expect(data).toEqual({ path: ["hello-world", "test_file", "page.html"] });
server.stop(true);
});
it("prioritizes more specific routes", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("fallback"),
routes: {
"/api/users/:id": () => new Response("specific"),
"/api/:path*": () => new Response("catch-all"),
},
});
server.unref();
const res1 = await fetch(`${server.url}api/users/123`);
expect(await res1.text()).toBe("specific");
const res2 = await fetch(`${server.url}api/users/123/posts`);
expect(await res2.text()).toBe("catch-all");
server.stop(true);
});
});