Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/runtime/http/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,8 @@ Use this for development and hot reloading. You can update only `fetch`, `error`

## Per-Request Controls

These methods act on a `Request` that this server received. When several servers share one handler, use the `server` argument that Bun passes to `fetch` and to route handlers: calling `timeout()`, `requestIP()`, or `upgrade()` on a different `Bun.serve()` instance with that request throws a `TypeError`.

### `server.timeout(Request, seconds)`

Override the idle timeout for an individual request. Pass `0` to disable the timeout entirely for that request.
Expand Down
5 changes: 5 additions & 0 deletions packages/bun-types/serve.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,7 @@ declare module "bun" {
* @param options Pass headers or attach data to the {@link ServerWebSocket}
*
* @returns `true` if the upgrade was successful and `false` if it failed
* @throws {TypeError} If `request` was received by a different server
*
* @example
* ```js
Expand Down Expand Up @@ -1125,6 +1126,8 @@ declare module "bun" {
/**
* Returns the client IP address and port of the given Request. If the request was closed or is a unix socket, returns null.
*
* @throws {TypeError} If `request` was received by a different server
*
* @example
* ```js
* export default {
Expand All @@ -1139,6 +1142,8 @@ declare module "bun" {
/**
* Reset the idle timeout of the given Request to the given number of seconds. `0` means no timeout.
*
* @throws {TypeError} If `request` was received by a different server
*
* @example
* ```js
* export default {
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/server/AnyRequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ impl AnyRequestContext {
dispatch!(self, false, |_T, ctx| ctx.set_timeout(seconds))
}

/// Type-erased pointer to the `NewServer` that received this request;
/// null when there is no context or it already detached from its server.
pub(crate) fn server_ptr(self) -> *const () {
dispatch!(self, core::ptr::null(), |_T, ctx| {
ctx.server
.get()
.map_or(core::ptr::null(), |s| s.as_ptr().cast_const().cast())
})
}

pub(crate) fn set_cookies(self, cookie_map: Option<*mut CookieMap>) {
dispatch!(self, (), |_T, ctx| ctx.set_cookies(cookie_map))
}
Expand Down
30 changes: 28 additions & 2 deletions src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1526,10 +1526,30 @@ where
self.timeout(global, callframe)
}

/// A live `Request` (or node:http response) that another `Bun.serve()`
/// instance dispatched must not be acted on through this one. `owner` is
/// null once the request detached from its server; those calls keep their
/// no-op behavior.
fn check_request_owner(&self, owner: *const (), method: &str) -> JsResult<()> {
if owner.is_null() || core::ptr::eq(owner, core::ptr::from_ref(self).cast()) {
return Ok(());
}
Err(self
.global()
.err(
jsc::ErrorCode::INVALID_ARG_VALUE,
format_args!(
"server.{method}() was called with a Request that a different server received. Use the server passed to fetch() as the second argument."
),
)
.throw())
}

pub(crate) fn request_ip(&self, request: &Request) -> JsResult<JSValue> {
if matches!(self.config.address, server_config::Address::Unix(_)) {
return Ok(JSValue::NULL);
}
self.check_request_owner(request.request_context.server_ptr(), "requestIP")?;
let Some(info) = request.request_context.get_remote_socket_info() else {
return Ok(JSValue::NULL);
};
Expand Down Expand Up @@ -1567,11 +1587,15 @@ where

if let Some(request) = <Request as bun_jsc::JsClass>::from_js(arguments[0]) {
// SAFETY: from_js returns a live *mut Request; shared access only.
let _ = unsafe { (*request).request_context.set_timeout(value) };
let request_context = unsafe { (*request).request_context };
self.check_request_owner(request_context.server_ptr(), "timeout")?;
let _ = request_context.set_timeout(value);
} else if let Some(response) = <NodeHTTPResponse as bun_jsc::JsClass>::from_js(arguments[0])
{
// SAFETY: from_js returns a live *mut NodeHTTPResponse
unsafe { (*response).set_timeout((value % 255) as u8) };
let response = unsafe { &*response };
self.check_request_owner(response.server.ptr.cast_const(), "timeout")?;
response.set_timeout((value % 255) as u8);
} else {
return Err(self
.global()
Expand Down Expand Up @@ -1673,6 +1697,7 @@ where
// SAFETY: from_js returns a live *mut NodeHTTPResponse; shared —
// its mutable state is `Cell`/`JsCell` and `upgrade` takes `&self`.
let node_http_response = unsafe { &*node_http_response };
self.check_request_owner(node_http_response.server.ptr.cast_const(), "upgrade")?;
if node_http_response
.flags
.get()
Expand Down Expand Up @@ -1792,6 +1817,7 @@ where
// argument). Shared, re-derived after each JS re-entry point below; the
// one field write at the end goes through `request_ptr`.
let request = unsafe { &*request_ptr };
self.check_request_owner(request.request_context.server_ptr(), "upgrade")?;

let Some(upgrader_ptr) = request
.request_context
Expand Down
53 changes: 53 additions & 0 deletions test/js/bun/http/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3160,6 +3160,59 @@ it.concurrent(
20_000,
);

it.concurrent("timeout(), requestIP() and upgrade() throw for a Request that a different server received", async () => {
const calls: Record<string, unknown> = {};
const capture = (label: string, fn: () => unknown) => {
try {
calls[label] = { returned: fn() };
} catch (e: any) {
calls[label] = { threw: { name: e?.name, code: e?.code, message: e?.message } };
}
};
const opened = Promise.withResolvers<string>();
using other = Bun.serve({
port: 0,
websocket: { open: () => opened.resolve("other"), message() {} },
fetch: () => new Response("other"),
});
using server = Bun.serve({
port: 0,
websocket: { open: () => opened.resolve("server"), message() {} },
fetch(req, server) {
capture("other.timeout", () => other.timeout(req, 0));
capture("other.requestIP", () => other.requestIP(req));
capture("other.upgrade", () => other.upgrade(req));
capture("server.timeout", () => server.timeout(req, 0));
capture("server.requestIP", () => server.requestIP(req)?.family);
capture("server.upgrade", () => server.upgrade(req));
if ((calls["server.upgrade"] as any)?.returned === true) return;
return new Response("not upgraded", { status: 400 });
},
});
const ws = new WebSocket(server.url.href.replace(/^http/, "ws"));
const clientOpened = Promise.withResolvers<void>();
ws.onopen = () => clientOpened.resolve();
ws.onerror = () => clientOpened.reject(new Error("handshake failed: " + JSON.stringify(calls)));
ws.onclose = e => clientOpened.reject(new Error(`closed ${e.code} before open: ` + JSON.stringify(calls)));
await clientOpened.promise;
ws.close();
const foreign = (method: string) => ({
name: "TypeError",
code: "ERR_INVALID_ARG_VALUE",
message: `server.${method}() was called with a Request that a different server received. Use the server passed to fetch() as the second argument.`,
});
expect(calls).toEqual({
"other.timeout": { threw: foreign("timeout") },
"other.requestIP": { threw: foreign("requestIP") },
"other.upgrade": { threw: foreign("upgrade") },
"server.timeout": { returned: undefined },
"server.requestIP": { returned: expect.stringMatching(/^IPv[46]$/) },
"server.upgrade": { returned: true },
});
// The socket was upgraded onto the server that received it, not `other`.
expect(await opened.promise).toBe("server");
});

it.concurrent("#6462", async () => {
let headers: string[] = [];
using server = Bun.serve({
Expand Down
Loading