Compare commits

...

2 Commits

Author SHA1 Message Date
Jarred Sumner
2c9171e57c Forgot to commit this 2025-05-15 22:45:27 -07:00
Jarred Sumner
b433e9000f Add request.searchParams 2025-05-15 22:06:54 -07:00
8 changed files with 568 additions and 5 deletions

View File

@@ -62,6 +62,7 @@ Routes in `Bun.serve()` receive a `BunRequest` (which extends [`Request`](https:
interface BunRequest<T extends string> extends Request {
params: Record<T, string>;
readonly cookies: CookieMap;
readonly searchParams: URLSearchParams;
}
```
@@ -132,6 +133,47 @@ Bun.serve({
Percent-encoded route parameter values are automatically decoded. Unicode characters are supported. Invalid unicode is replaced with the unicode replacement character `&0xFFFD;`.
#### Query parameters
Request query parameters (the part after `?` in the URL) are accessible via `request.searchParams`, which returns a standard [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) object.
```ts
import { serve } from "bun";
serve({
routes: {
"/search": req => {
// URL: /search?q=bun&limit=10
// Get a single value
const query = req.searchParams.get("q"); // "bun"
// Get a value with fallback
const limit = req.searchParams.get("limit") || "20"; // "10"
// Check if parameter exists
const hasFilter = req.searchParams.has("filter"); // false
// Get all values for a parameter (useful for repeated params)
const tags = req.searchParams.getAll("tag"); // [] (empty if none)
// Convert to plain object
const allParams = Object.fromEntries(req.searchParams); // { q: "bun", limit: "10" }
return Response.json({ query, limit: Number(limit), hasFilter, tags });
},
// Works with route parameters too
"/users/:id/posts": req => {
const userId = req.params.id;
const page = req.searchParams.get("page") || "1";
return Response.json({ userId, page: Number(page) });
},
},
});
```
### Static responses
Routes can also be `Response` objects (without the handler function). Bun.serve() optimizes it for zero-allocation dispatch - perfect for health checks, redirects, and fixed content:

View File

@@ -3302,7 +3302,66 @@ declare module "bun" {
}
interface BunRequest<T extends string = string> extends Request {
/**
* The `:param` part of the URL as an object. If the URL is `/users/:id`,
* then `req.params` is `{ id: "123" }`. If the URL has no parameters, its
* an empty object.
*
* @example
* ```ts
* const server = Bun.serve({
* routes: {
* "/:foo/:bar": (req, server) => {
* console.log(req.params.foo); // "bar"
* }
* }
* })
* await fetch(`${server.url}/foo/bar`)
* ```
*/
params: RouterTypes.ExtractRouteParams<T>;
/**
* The `?query` part of the URL as a {@link URLSearchParams} object.
*
* If no query is provided, it returns an empty URLSearchParams object.
*
* @example
*
* ```ts
* const server = Bun.serve({
* routes: {
* "/": (req, server) => {
* console.log(req.query.get("foo")); // "bar"
* }
* }
* })
*
* await fetch(`${server.url}/?foo=bar`)
* ```
*/
readonly searchParams: URLSearchParams;
/**
* The `Cookie` header as a {@link CookieMap} object.
*
* @example
* ```ts
* const server = Bun.serve({
* routes: {
* "/": (req, server) => {
* console.log(req.cookies.get("foo")); // "bar"
* }
* }
* })
*
* await fetch(`${server.url}`, {
* headers: {
* Cookie: "foo=bar"
* }
* })
* ```
*/
readonly cookies: CookieMap;
}

View File

@@ -12,13 +12,20 @@
#include "Cookie.h"
#include "CookieMap.h"
#include "JSDOMExceptionHandling.h"
#include <bun-uws/src/App.h>
#include "JSURLSearchParams.h"
#include "URLSearchParams.h"
#include <wtf/URLParser.h>
namespace Bun {
extern "C" uWS::HttpRequest* Request__getUWSRequest(JSBunRequest*);
static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetParams);
static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetCookies);
static JSC_DECLARE_CUSTOM_GETTER(jsJSBunRequestGetQuery);
static const HashTableValue JSBunRequestPrototypeValues[] = {
{ "searchParams"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetQuery, nullptr } },
{ "params"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetParams, nullptr } },
{ "cookies"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsJSBunRequestGetCookies, nullptr } },
};
@@ -66,6 +73,19 @@ JSObject* JSBunRequest::cookies() const
return nullptr;
}
JSObject* JSBunRequest::query() const
{
if (m_query) {
return m_query.get();
}
return nullptr;
}
void JSBunRequest::setQuery(JSObject* query)
{
m_query.set(Base::vm(), this, query);
}
extern "C" void Request__setCookiesOnRequestContext(void* internalZigRequestPointer, CookieMap* cookieMap);
void JSBunRequest::setCookies(JSObject* cookies)
@@ -85,6 +105,7 @@ void JSBunRequest::finishCreation(JSC::VM& vm, JSObject* params)
Base::finishCreation(vm);
m_params.setMayBeNull(vm, this, params);
m_cookies.clear();
m_query.clear();
Bun__JSRequest__calculateEstimatedByteSize(this->wrapped());
auto size = Request__estimatedSize(this->wrapped());
@@ -98,6 +119,7 @@ void JSBunRequest::visitChildrenImpl(JSCell* cell, Visitor& visitor)
Base::visitChildren(thisCallSite, visitor);
visitor.append(thisCallSite->m_params);
visitor.append(thisCallSite->m_cookies);
visitor.append(thisCallSite->m_query);
}
DEFINE_VISIT_CHILDREN(JSBunRequest);
@@ -162,6 +184,73 @@ JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetParams, (JSC::JSGlobalObject * globalO
return JSValue::encode(params);
}
static JSValue createQueryObject(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSBunRequest* request)
{
auto scope = DECLARE_THROW_SCOPE(vm);
auto* uws = Request__getUWSRequest(request);
auto* global = defaultGlobalObject(globalObject);
// First, try to get it from uWS::HttpRequest
if (uws) {
auto query = uws->getQuery();
auto span = std::span<const uint8_t>(reinterpret_cast<const uint8_t*>(query.data()), query.size());
// This should always be URL-encoded
WTF::String queryString = WTF::String::fromUTF8ReplacingInvalidSequences(span);
auto searchParams = WebCore::URLSearchParams::create(queryString, nullptr);
return WebCore::toJSNewlyCreated(global, global, WTFMove(searchParams));
}
// Otherwise, get it by reading the url property.
auto& names = builtinNames(vm);
auto url = request->get(globalObject, names.urlPublicName());
RETURN_IF_EXCEPTION(scope, {});
auto* urlString = url.toString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto view = urlString->view(globalObject);
RETURN_IF_EXCEPTION(scope, {});
// Figure out where the query string is
const auto findQustionMark = view->find('?');
WTF::StringView queryView;
if (findQustionMark != WTF::notFound) {
queryView = view->substring(findQustionMark + 1, view->length() - findQustionMark - 1);
}
// Parse the query string
auto searchParams = queryView.length() > 0 ? WebCore::URLSearchParams::create(WTF::URLParser::parseURLEncodedForm(queryView)) : WebCore::URLSearchParams::create({});
// If for any reason that failed, throw an error
if (searchParams.hasException()) [[unlikely]] {
WebCore::propagateException(*globalObject, scope, searchParams.releaseException());
return {};
}
return WebCore::toJSNewlyCreated(global, global, searchParams.releaseReturnValue());
}
JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetQuery, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName))
{
JSBunRequest* request = jsDynamicCast<JSBunRequest*>(JSValue::decode(thisValue));
if (!request)
return JSValue::encode(jsUndefined());
if (auto* query = request->query()) {
return JSValue::encode(query);
}
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
auto result = createQueryObject(vm, globalObject, request);
RETURN_IF_EXCEPTION(scope, {});
request->setQuery(result.getObject());
return JSValue::encode(result);
}
JSC_DEFINE_CUSTOM_GETTER(jsJSBunRequestGetCookies, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName))
{
JSBunRequest* request = jsDynamicCast<JSBunRequest*>(JSValue::decode(thisValue));
@@ -224,4 +313,23 @@ extern "C" EncodedJSValue Bun__getParamsIfBunRequest(JSC::EncodedJSValue thisVal
return JSValue::encode({});
}
extern "C" EncodedJSValue Bun__getQueryIfBunRequest(JSC::EncodedJSValue thisValue)
{
if (auto* request = jsDynamicCast<JSBunRequest*>(JSValue::decode(thisValue))) {
if (auto* query = request->query()) {
return JSValue::encode(query);
}
auto* globalObject = request->globalObject();
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
auto result = createQueryObject(vm, globalObject, request);
RETURN_IF_EXCEPTION(scope, encodedJSValue());
request->setQuery(result.getObject());
return JSValue::encode(result);
}
return JSValue::encode(jsUndefined());
}
} // namespace Bun

View File

@@ -36,12 +36,16 @@ public:
JSObject* cookies() const;
void setCookies(JSObject* cookies);
JSObject* query() const;
void setQuery(JSObject* query);
private:
JSBunRequest(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr);
void finishCreation(JSC::VM& vm, JSObject* params);
mutable JSC::WriteBarrier<JSC::JSObject> m_params;
mutable JSC::WriteBarrier<JSC::JSObject> m_cookies;
mutable JSC::WriteBarrier<JSC::JSObject> m_query;
};
JSC::Structure* createJSBunRequestStructure(JSC::VM&, Zig::GlobalObject*);

View File

@@ -259,7 +259,7 @@ JSValue ServerRouteList::callRoute(Zig::GlobalObject* globalObject, uint32_t ind
args.append(request);
args.append(serverValue);
return AsyncContextFrame::call(globalObject, callback, serverValue, args);
return AsyncContextFrame::profiledCall(globalObject, callback, serverValue, args);
}
extern "C" JSC::EncodedJSValue Bun__ServerRouteList__callRoute(

View File

@@ -171,12 +171,14 @@ pub fn toJS(this: *Request, globalObject: *JSGlobalObject) JSValue {
return js.toJSUnchecked(globalObject, this);
}
extern "JS" fn Bun__getParamsIfBunRequest(this_value: JSValue) JSValue;
extern "c" fn Bun__getParamsIfBunRequest(this_value: JSValue) JSValue;
extern "c" fn Bun__getQueryIfBunRequest(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);
const params_object = Bun__getParamsIfBunRequest(this_value);
const query_object = Bun__getQueryIfBunRequest(this_value);
const class_label = switch (params_object) {
.zero => "Request",
@@ -205,7 +207,16 @@ pub fn writeFormat(this: *Request, this_value: JSValue, comptime Formatter: type
if (params_object.isCell()) {
try formatter.writeIndent(Writer, writer);
try writer.writeAll(comptime Output.prettyFmt("<r>params<d>:<r> ", enable_ansi_colors));
try formatter.printAs(.Private, Writer, writer, params_object, .Object, enable_ansi_colors);
try formatter.printAs(.Object, Writer, writer, params_object, .Object, enable_ansi_colors);
formatter.printComma(Writer, writer, enable_ansi_colors) catch unreachable;
try writer.writeAll("\n");
}
if (query_object.isCell()) {
try formatter.writeIndent(Writer, writer);
try writer.writeAll(comptime Output.prettyFmt("<r>searchParams<d>:<r> ", enable_ansi_colors));
const tag = if (Formatter == JSC.ConsoleObject.Formatter) .toJSON else .Object;
try formatter.printAs(tag, Writer, writer, query_object, query_object.jsType(), enable_ansi_colors);
formatter.printComma(Writer, writer, enable_ansi_colors) catch unreachable;
try writer.writeAll("\n");
}

View File

@@ -34,7 +34,7 @@ const words: Record<string, { reason: string; limit?: number; regex?: boolean }>
[String.raw`: [a-zA-Z0-9_\.\*\?\[\]\(\)]+ = undefined,`]: { reason: "Do not default a struct field to undefined", limit: 240, regex: true },
"usingnamespace": { reason: "Zig 0.15 will remove `usingnamespace`" },
"catch unreachable": { reason: "For out-of-memory, prefer 'catch bun.outOfMemory()'", limit: 1850 },
"catch unreachable": { reason: "For out-of-memory, prefer 'catch bun.outOfMemory()'", limit: 1851 },
"std.fs.Dir": { reason: "Prefer bun.sys + bun.FD instead of std.fs", limit: 180 },
"std.fs.cwd": { reason: "Prefer bun.FD.cwd()", limit: 103 },

View File

@@ -0,0 +1,339 @@
import type { BunRequest, Server } from "bun";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
let server: Server;
afterAll(() => {
server.stop(true);
});
beforeAll(() => {
server = Bun.serve({
port: 0,
routes: {
"/echo-query": req => {
return new Response("hello");
},
},
});
server.unref();
});
describe("request.searchParams basic functionality", () => {
beforeEach(() => {
server.reload({
port: 0,
routes: {
"/echo-query": req => {
if (!req.searchParams) {
throw new Error("query is undefined");
}
if (!(req.searchParams instanceof URLSearchParams)) {
throw new Error("query is not a URLSearchParams");
}
console.log(req);
return new Response(
JSON.stringify({
// Convert URLSearchParams to plain object for easier assertions
asObject: Object.fromEntries(req.searchParams),
// Also test the raw URLSearchParams toString() result
asString: req.searchParams.toString(),
}),
);
},
},
});
});
it("handles simple query parameters", async () => {
const res = await fetch(`${server.url}echo-query?foo=bar`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({
foo: "bar",
});
expect(data.asString).toBe("foo=bar");
});
it("handles multiple query parameters", async () => {
const res = await fetch(`${server.url}echo-query?foo=bar&baz=qux`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({
foo: "bar",
baz: "qux",
});
expect(data.asString).toBe("foo=bar&baz=qux");
});
it("handles empty query parameters", async () => {
const res = await fetch(`${server.url}echo-query?empty=&foo=bar`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({
empty: "",
foo: "bar",
});
expect(data.asString).toBe("empty=&foo=bar");
});
it("handles key-only query parameters", async () => {
const res = await fetch(`${server.url}echo-query?flag&foo=bar`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({
flag: "",
foo: "bar",
});
expect(data.asString).toBe("flag=&foo=bar");
});
it("handles no query parameters", async () => {
const res = await fetch(`${server.url}echo-query`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({});
expect(data.asString).toBe("");
});
it("handles encoded query parameters", async () => {
const res = await fetch(`${server.url}echo-query?email=user%40example.com&message=hello%20world`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({
email: "user@example.com",
message: "hello world",
});
});
it("handles unicode query parameters", async () => {
const res = await fetch(`${server.url}echo-query?emoji=🦊&text=こんにちは`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({
emoji: "🦊",
text: "こんにちは",
});
});
});
describe("request.searchParams URLSearchParams methods", () => {
beforeEach(() => {
server.reload({
port: 0,
routes: {
"/has": req => new Response(req.searchParams.has("key").toString()),
"/get": req => new Response(req.searchParams.get("key") || "null"),
"/getAll": req => new Response(JSON.stringify(req.searchParams.getAll("key"))),
"/entries": req => {
const entries = Array.from(req.searchParams.entries());
return new Response(JSON.stringify(entries));
},
"/keys": req => {
const keys = Array.from(req.searchParams.keys());
return new Response(JSON.stringify(keys));
},
"/values": req => {
const values = Array.from(req.searchParams.values());
return new Response(JSON.stringify(values));
},
},
});
});
it("implements has() method", async () => {
let res = await fetch(`${server.url}has?key=value`);
expect(await res.text()).toBe("true");
res = await fetch(`${server.url}has?otherkey=value`);
expect(await res.text()).toBe("false");
});
it("implements get() method", async () => {
let res = await fetch(`${server.url}get?key=value`);
expect(await res.text()).toBe("value");
res = await fetch(`${server.url}get?otherkey=value`);
expect(await res.text()).toBe("null");
});
it("implements getAll() method for repeated parameters", async () => {
const res = await fetch(`${server.url}getAll?key=value1&key=value2&key=value3`);
const values = await res.json();
expect(values).toEqual(["value1", "value2", "value3"]);
});
it("implements entries() method", async () => {
const res = await fetch(`${server.url}entries?a=1&b=2&c=3`);
const entries = await res.json();
expect(entries).toEqual([
["a", "1"],
["b", "2"],
["c", "3"],
]);
});
it("implements keys() method", async () => {
const res = await fetch(`${server.url}keys?a=1&b=2&c=3`);
const keys = await res.json();
expect(keys).toEqual(["a", "b", "c"]);
});
it("implements values() method", async () => {
const res = await fetch(`${server.url}values?a=1&b=2&c=3`);
const values = await res.json();
expect(values).toEqual(["1", "2", "3"]);
});
});
describe("request.searchParams with route parameters", () => {
beforeEach(() => {
server.reload({
port: 0,
routes: {
"/users/:id": (req: BunRequest<"/users/:id">) => {
return new Response(
JSON.stringify({
params: req.params,
query: Object.fromEntries(req.searchParams),
}),
);
},
},
});
});
it("combines route parameters with query parameters", async () => {
const res = await fetch(`${server.url}users/123?sort=name&filter=active`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({
params: { id: "123" },
query: {
sort: "name",
filter: "active",
},
});
});
});
describe("request.searchParams manipulation", () => {
beforeEach(() => {
server.reload({
port: 0,
routes: {
"/append": req => {
const query = req.searchParams;
query.append("added", "value");
return new Response(query.toString());
},
"/set": req => {
const query = req.searchParams;
query.set("key", "newvalue");
return new Response(query.toString());
},
"/delete": req => {
const query = req.searchParams;
query.delete("key");
return new Response(query.toString());
},
},
});
});
it("allows appending new parameters", async () => {
const res = await fetch(`${server.url}append?existing=value`);
expect(await res.text()).toBe("existing=value&added=value");
});
it("allows setting parameter values", async () => {
const res = await fetch(`${server.url}set?key=oldvalue&other=value`);
expect(await res.text()).toBe("key=newvalue&other=value");
});
it("allows deleting parameters", async () => {
const res = await fetch(`${server.url}delete?key=value&other=value`);
expect(await res.text()).toBe("other=value");
});
});
describe("request.searchParams in async handlers", () => {
beforeEach(() => {
server.reload({
port: 0,
routes: {
"/async-echo-query": async req => {
await Bun.sleep(1);
if (!req.searchParams) {
throw new Error("query is undefined in async handler");
}
if (!(req.searchParams instanceof URLSearchParams)) {
throw new Error("query is not a URLSearchParams in async handler");
}
return new Response(
JSON.stringify({
asObject: Object.fromEntries(req.searchParams),
asString: req.searchParams.toString(),
}),
);
},
"/async-methods": async req => {
await Bun.sleep(1);
const result = {
has: req.searchParams.has("key"),
get: req.searchParams.get("key"),
getAll: req.searchParams.getAll("key"),
entries: Array.from(req.searchParams.entries()),
keys: Array.from(req.searchParams.keys()),
values: Array.from(req.searchParams.values()),
};
return new Response(JSON.stringify(result));
},
"/async-manipulation": async req => {
await Bun.sleep(1);
const query = req.searchParams;
query.append("added", "value");
query.set("existing", "updated");
if (query.has("delete")) {
query.delete("delete");
}
return new Response(query.toString());
},
},
});
});
it("handles query parameters in async handlers", async () => {
const res = await fetch(`${server.url}async-echo-query?foo=bar&baz=qux`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.asObject).toEqual({
foo: "bar",
baz: "qux",
});
expect(data.asString).toBe("foo=bar&baz=qux");
});
it("supports URLSearchParams methods in async handlers", async () => {
const res = await fetch(`${server.url}async-methods?key=value&other=test`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.has).toBe(true);
expect(data.get).toBe("value");
expect(data.getAll).toEqual(["value"]);
expect(data.entries).toEqual([
["key", "value"],
["other", "test"],
]);
expect(data.keys).toEqual(["key", "other"]);
expect(data.values).toEqual(["value", "test"]);
});
it("allows manipulation in async handlers", async () => {
const res = await fetch(`${server.url}async-manipulation?existing=original&delete=remove`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("existing=updated&added=value");
});
});