[1.3] Bun.serve({ websocket }) types (#20918)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Alistair Smith
2025-10-06 16:07:36 -07:00
committed by GitHub
parent 3c232b0fb4
commit b22e19baed
15 changed files with 1699 additions and 1507 deletions

View File

@@ -114,8 +114,7 @@ type WebSocketData = {
authToken: string;
};
// TypeScript: specify the type of `data`
Bun.serve<WebSocketData>({
Bun.serve({
fetch(req, server) {
const cookies = new Bun.CookieMap(req.headers.get("cookie")!);
@@ -131,8 +130,12 @@ Bun.serve<WebSocketData>({
return undefined;
},
websocket: {
// TypeScript: specify the type of ws.data like this
data: {} as WebSocketData,
// handler called when a message is received
async message(ws, message) {
// ws.data is now properly typed as WebSocketData
const user = getUserFromToken(ws.data.authToken);
await saveMessageToDatabase({
@@ -164,7 +167,7 @@ socket.addEventListener("message", event => {
Bun's `ServerWebSocket` implementation implements a native publish-subscribe API for topic-based broadcasting. Individual sockets can `.subscribe()` to a topic (specified with a string identifier) and `.publish()` messages to all other subscribers to that topic (excluding itself). This topic-based broadcast API is similar to [MQTT](https://en.wikipedia.org/wiki/MQTT) and [Redis Pub/Sub](https://redis.io/topics/pubsub).
```ts
const server = Bun.serve<{ username: string }>({
const server = Bun.serve({
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/chat") {
@@ -179,6 +182,9 @@ const server = Bun.serve<{ username: string }>({
return new Response("Hello world");
},
websocket: {
// TypeScript: specify the type of ws.data like this
data: {} as { username: string },
open(ws) {
const msg = `${ws.data.username} has entered the chat`;
ws.subscribe("the-group-chat");

View File

@@ -7,7 +7,7 @@ When building a WebSocket server, it's typically necessary to store some identif
With [Bun.serve()](https://bun.com/docs/api/websockets#contextual-data), this "contextual data" is set when the connection is initially upgraded by passing a `data` parameter in the `server.upgrade()` call.
```ts
Bun.serve<{ socketId: number }>({
Bun.serve({
fetch(req, server) {
const success = server.upgrade(req, {
data: {
@@ -20,6 +20,9 @@ Bun.serve<{ socketId: number }>({
// ...
},
websocket: {
// TypeScript: specify the type of ws.data like this
data: {} as { socketId: number },
// define websocket handlers
async message(ws, message) {
// the contextual data is available as the `data` property
@@ -41,8 +44,7 @@ type WebSocketData = {
userId: string;
};
// TypeScript: specify the type of `data`
Bun.serve<WebSocketData>({
Bun.serve({
async fetch(req, server) {
// use a library to parse cookies
const cookies = parseCookies(req.headers.get("Cookie"));
@@ -60,6 +62,9 @@ Bun.serve<WebSocketData>({
if (upgraded) return undefined;
},
websocket: {
// TypeScript: specify the type of ws.data like this
data: {} as WebSocketData,
async message(ws, message) {
// save the message to a database
await saveMessageToDatabase({

View File

@@ -7,7 +7,7 @@ Bun's server-side `WebSocket` API provides a native pub-sub API. Sockets can be
This code snippet implements a simple single-channel chat server.
```ts
const server = Bun.serve<{ username: string }>({
const server = Bun.serve({
fetch(req, server) {
const cookies = req.headers.get("cookie");
const username = getUsernameFromCookies(cookies);
@@ -17,6 +17,9 @@ const server = Bun.serve<{ username: string }>({
return new Response("Hello world");
},
websocket: {
// TypeScript: specify the type of ws.data like this
data: {} as { username: string },
open(ws) {
const msg = `${ws.data.username} has entered the chat`;
ws.subscribe("the-group-chat");

View File

@@ -7,7 +7,7 @@ Start a simple WebSocket server using [`Bun.serve`](https://bun.com/docs/api/htt
Inside `fetch`, we attempt to upgrade incoming `ws:` or `wss:` requests to WebSocket connections.
```ts
const server = Bun.serve<{ authToken: string }>({
const server = Bun.serve({
fetch(req, server) {
const success = server.upgrade(req);
if (success) {

File diff suppressed because it is too large Load Diff

View File

@@ -3,5 +3,3 @@ import * as BunModule from "bun";
declare global {
export import Bun = BunModule;
}
export {};

View File

@@ -98,6 +98,11 @@ declare module "bun" {
): void;
}
/**
* @deprecated Use {@link Serve.Options Bun.Serve.Options<T, R>} instead
*/
type ServeOptions<T = undefined, R extends string = never> = Serve.Options<T, R>;
/** @deprecated Use {@link SQL.Query Bun.SQL.Query} */
type SQLQuery<T = any> = SQL.Query<T>;

View File

@@ -21,6 +21,7 @@
/// <reference path="./redis.d.ts" />
/// <reference path="./shell.d.ts" />
/// <reference path="./experimental.d.ts" />
/// <reference path="./serve.d.ts" />
/// <reference path="./sql.d.ts" />
/// <reference path="./security.d.ts" />

1272
packages/bun-types/serve.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

12
src/bake/bake.d.ts vendored
View File

@@ -5,8 +5,6 @@
// /// <reference path="/path/to/bun/src/bake/bake.d.ts" />
declare module "bun" {
type Awaitable<T> = T | Promise<T>;
declare namespace Bake {
interface Options {
/**
@@ -369,7 +367,7 @@ declare module "bun" {
* A common pattern would be to enforce the object is
* `{ default: ReactComponent }`
*/
render: (request: Request, routeMetadata: RouteMetadata) => Awaitable<Response>;
render: (request: Request, routeMetadata: RouteMetadata) => MaybePromise<Response>;
/**
* Prerendering does not use a request, and is allowed to generate
* multiple responses. This is used for static site generation, but not
@@ -379,7 +377,7 @@ declare module "bun" {
* Note that `import.meta.env.STATIC` will be inlined to true during
* a static build.
*/
prerender?: (routeMetadata: RouteMetadata) => Awaitable<PrerenderResult | null>;
prerender?: (routeMetadata: RouteMetadata) => MaybePromise<PrerenderResult | null>;
// TODO: prerenderWithoutProps (for partial prerendering)
/**
* For prerendering routes with dynamic parameters, such as `/blog/:slug`,
@@ -409,7 +407,7 @@ declare module "bun" {
* return { exhaustive: false };
* }
*/
getParams?: (paramsMetadata: ParamsMetadata) => Awaitable<GetParamIterator>;
getParams?: (paramsMetadata: ParamsMetadata) => MaybePromise<GetParamIterator>;
/**
* When a dynamic build uses static assets, Bun can map content types in the
* user's `Accept` header to the different static files.
@@ -448,7 +446,7 @@ declare module "bun" {
}
interface DevServerHookEntryPoint {
default: (dev: DevServerHookAPI) => Awaitable<void>;
default: (dev: DevServerHookAPI) => MaybePromise<void>;
}
interface DevServerHookAPI {
@@ -505,7 +503,7 @@ declare module "bun" {
}
}
declare interface GenericServeOptions {
declare interface BaseServeOptions {
/** Add a fullstack web app to this server using Bun Bake */
app?: Bake.Options | undefined;
}

View File

@@ -15,8 +15,6 @@ import os from "node:os";
import { dirname, isAbsolute, join } from "path";
import * as numeric from "_util/numeric.ts";
type Awaitable<T> = T | Promise<T>;
export const BREAKING_CHANGES_BUN_1_2 = false;
export const isMacOS = process.platform === "darwin";
@@ -184,7 +182,7 @@ export type DirectoryTree = {
| string
| Buffer
| DirectoryTree
| ((opts: { root: string }) => Awaitable<string | Buffer | DirectoryTree>);
| ((opts: { root: string }) => Bun.MaybePromise<string | Buffer | DirectoryTree>);
};
export async function makeTree(base: string, tree: DirectoryTree) {

View File

@@ -605,7 +605,7 @@ describe("@types/bun integration test", () => {
},
{
code: 2345,
line: "index.ts:326:29",
line: "index.ts:322:29",
message:
"Argument of type '{ headers: { \"x-bun\": string; }; }' is not assignable to parameter of type 'number'.",
},

View File

@@ -270,10 +270,6 @@ Bun.serve({
port: 3000,
fetch: () => new Response("ok"),
// don't do this, use the `tls: {}` options instead
key: Bun.file(""), // dont do it!
cert: Bun.file(""), // dont do it!
tls: {
key: Bun.file(""), // do this!
cert: Bun.file(""), // do this!

View File

@@ -13,27 +13,35 @@ function tmpdirSync(pattern: string = "bun.test."): string {
return fs.mkdtempSync(join(fs.realpathSync.native(os.tmpdir()), pattern));
}
export default {
fetch: req => Response.json(req.url),
websocket: {
message(ws) {
expectType(ws.data).is<{ name: string }>();
},
},
} satisfies Bun.ServeOptions<{ name: string }>;
function expectInstanceOf<T>(value: unknown, constructor: new (...args: any[]) => T): asserts value is T {
expect(value).toBeInstanceOf(constructor);
}
function test<T, R extends { [K in keyof R]: Bun.RouterTypes.RouteValue<K & string> }>(
function test<T = undefined, R extends string = never>(
name: string,
serveConfig: Bun.ServeFunctionOptions<T, R>,
options: Bun.Serve.Options<T, R>,
{
onConstructorFailure,
overrideExpectBehavior,
skip: skipOptions,
}: {
onConstructorFailure?: (error: Error) => void | Promise<void>;
overrideExpectBehavior?: (server: Bun.Server) => void | Promise<void>;
overrideExpectBehavior?: (server: NoInfer<Bun.Server<T>>) => void | Promise<void>;
skip?: boolean;
} = {},
) {
if ("unix" in serveConfig && typeof serveConfig.unix === "string" && process.platform === "win32") {
// Skip unix socket tests on Windows
return;
}
const skip = skipOptions || ("unix" in options && typeof options.unix === "string" && process.platform === "win32");
async function testServer(server: Bun.Server) {
async function testServer(server: Bun.Server<T>) {
if (overrideExpectBehavior) {
await overrideExpectBehavior(server);
} else {
@@ -45,9 +53,9 @@ function test<T, R extends { [K in keyof R]: Bun.RouterTypes.RouteValue<K & stri
}
}
it(name, async () => {
it.skipIf(skip)(name, async () => {
try {
using server = Bun.serve(serveConfig);
using server = Bun.serve(options);
try {
await testServer(server);
} finally {
@@ -107,18 +115,21 @@ test(
test("basic + websocket + upgrade", {
websocket: {
message(ws, message) {
expectType<typeof ws>().is<Bun.ServerWebSocket<unknown>>();
expectType<typeof ws>().is<Bun.ServerWebSocket<undefined>>();
ws.send(message);
expectType(message).is<string | Buffer<ArrayBuffer>>();
},
},
fetch(req, server) {
expectType(req).is<Request>();
// Upgrade to a ServerWebSocket if we can
// This automatically checks for the `Sec-WebSocket-Key` header
// meaning you don't have to check headers, you can just call `upgrade()`
if (server.upgrade(req)) {
// When upgrading, we return undefined since we don't want to send a Response
return;
// return;
}
return new Response("Regular HTTP response");
@@ -127,6 +138,16 @@ test("basic + websocket + upgrade", {
test("basic + websocket + upgrade + all handlers", {
fetch(req, server) {
expectType(server.upgrade).is<
(
req: Request,
options: {
data?: { name: string };
headers?: Bun.HeadersInit;
},
) => boolean
>;
const url = new URL(req.url);
if (url.pathname === "/chat") {
if (
@@ -147,20 +168,26 @@ test("basic + websocket + upgrade + all handlers", {
},
websocket: {
open(ws: Bun.ServerWebSocket<{ name: string }>) {
data: {} as { name: string },
open(ws) {
console.log("WebSocket opened");
ws.subscribe("the-group-chat");
},
message(ws, message) {
expectType(message).is<string | Buffer<ArrayBuffer>>();
ws.publish("the-group-chat", `${ws.data.name}: ${message.toString()}`);
},
close(ws, code, reason) {
expectType(code).is<number>();
expectType(reason).is<string>();
ws.publish("the-group-chat", `${ws.data.name} left the chat`);
},
drain(ws) {
expectType(ws.data.name).is<string>();
console.log("Please send me data. I am ready to receive it.");
},
@@ -201,7 +228,7 @@ test("port 0 + websocket + upgrade", {
},
websocket: {
message(ws) {
expectType(ws).is<Bun.ServerWebSocket<unknown>>();
expectType(ws).is<Bun.ServerWebSocket<undefined>>();
},
},
});
@@ -269,9 +296,13 @@ test(
{
unix: `${tmpdirSync()}/bun.sock`,
fetch(req, server) {
server.upgrade(req);
if (server.upgrade(req)) {
return;
}
return new Response();
},
websocket: { message() {} },
},
{
overrideExpectBehavior: server => {
@@ -504,11 +535,10 @@ test("basic websocket upgrade and ws publish/subscribe to topics", {
test(
"port with unix socket (is a type error)",
// This prettier-ignore exists because between TypeScript 5.8 and 5.9, the location of the error message changed, so
// to satisfy both we can just keep what would have been the two erroring lines on the same line
// prettier-ignore
// @ts-expect-error
{ unix: `${tmpdirSync()}/bun.sock`, port: 0,
// @ts-expect-error Cannot pass unix and port
{
unix: `${tmpdirSync()}/bun.sock`,
port: 0,
fetch() {
return new Response();
},
@@ -524,10 +554,10 @@ test(
test(
"port with unix socket with websocket + upgrade (is a type error)",
// Prettier ignore exists for same reason as above
// prettier-ignore
// @ts-expect-error
{ unix: `${tmpdirSync()}/bun.sock`, port: 0,
// @ts-expect-error cannot pass unix and port at same time
{
unix: `${tmpdirSync()}/bun.sock`,
port: 0,
fetch(req, server) {
server.upgrade(req);
if (Math.random() > 0.5) return undefined;
@@ -543,3 +573,246 @@ test(
},
},
);
test("hostname: 0.0.0.0 (default - listen on all interfaces)", {
hostname: "0.0.0.0",
fetch() {
return new Response("listening on all interfaces");
},
});
test("hostname: 127.0.0.1 (localhost only)", {
hostname: "127.0.0.1",
fetch() {
return new Response("listening on localhost only");
},
});
test("hostname: localhost", {
hostname: "localhost",
fetch() {
return new Response("listening on localhost");
},
});
test(
"hostname: custom IPv4 address",
{
hostname: "192.168.1.100",
fetch() {
return new Response("custom hostname");
},
},
{
onConstructorFailure: error => {
expect(error.message).toContain("Failed to start server");
},
},
);
test("port: number type", {
port: 3000,
fetch() {
return new Response("port as number");
},
});
test("port: string type", {
port: "3001",
fetch() {
return new Response("port as string");
},
});
test("port: 0 (random port assignment)", {
port: 0,
fetch() {
return new Response("random port");
},
});
test(
"port: from environment variable",
{
port: process.env.PORT || "3002",
fetch() {
return new Response("port from env");
},
},
{
overrideExpectBehavior: server => {
expect(server.port).toBeGreaterThan(0);
expect(server.url).toBeDefined();
},
},
);
test("reusePort: false (default)", {
reusePort: false,
port: 0,
fetch() {
return new Response("reusePort false");
},
});
test("reusePort: true", {
reusePort: true,
port: 0,
fetch() {
return new Response("reusePort true");
},
});
test("ipv6Only: false (default)", {
ipv6Only: false,
port: 0,
fetch() {
return new Response("ipv6Only false");
},
});
test("idleTimeout: default (10 seconds)", {
port: 0,
fetch() {
return new Response("default idleTimeout");
},
});
test("idleTimeout: custom value (30 seconds)", {
idleTimeout: 30,
port: 0,
fetch() {
return new Response("custom idleTimeout");
},
});
test("idleTimeout: 0 (no timeout)", {
idleTimeout: 0,
port: 0,
fetch() {
return new Response("no idleTimeout");
},
});
test("maxRequestBodySize: default (128MB)", {
port: 0,
fetch() {
return new Response("default maxRequestBodySize");
},
});
test("maxRequestBodySize: custom small value", {
maxRequestBodySize: 1024 * 1024, // 1MB
port: 0,
fetch() {
return new Response("small maxRequestBodySize");
},
});
test("maxRequestBodySize: custom large value", {
maxRequestBodySize: 1024 * 1024 * 1024, // 1GB
port: 0,
fetch() {
return new Response("large maxRequestBodySize");
},
});
test("development: true", {
development: true,
port: 0,
fetch() {
return new Response("development mode on");
},
});
test("development: false", {
development: false,
port: 0,
fetch() {
return new Response("development mode off");
},
});
test("development: defaults to process.env.NODE_ENV !== 'production'", {
development: process.env.NODE_ENV !== "production",
port: 0,
fetch() {
return new Response("development from env");
},
});
test(
"error callback handles errors",
{
port: 0,
fetch() {
throw new Error("Test error");
},
error(error) {
return new Response(`Error handled: ${error.message}`, { status: 500 });
},
},
{
overrideExpectBehavior: async server => {
const res = await fetch(server.url);
expect(res.status).toBe(500);
expect(await res.text()).toBe("Error handled: Test error");
},
},
);
test(
"error callback with async handler",
{
port: 0,
fetch() {
throw new Error("Async test error");
},
async error(error) {
await new Promise(resolve => setTimeout(resolve, 10));
return new Response(`Async error handled: ${error.message}`, { status: 503 });
},
},
{
overrideExpectBehavior: async server => {
const res = await fetch(server.url);
expect(res.status).toBe(503);
expect(await res.text()).toBe("Async error handled: Async test error");
},
},
);
test("id: custom server identifier", {
id: "my-custom-server-id",
port: 0,
fetch() {
return new Response("server with custom id");
},
});
test("id: null (no identifier)", {
id: null,
port: 0,
fetch() {
return new Response("server with null id");
},
});
test("multiple properties combined", {
hostname: "127.0.0.1",
port: 0,
reusePort: true,
idleTimeout: 20,
maxRequestBodySize: 1024 * 1024 * 10, // 10MB
development: true,
id: "combined-test-server",
fetch(req) {
return Response.json({
url: req.url,
method: req.method,
});
},
error(error) {
return new Response(`Combined server error: ${error.message}`, { status: 500 });
},
});

View File

@@ -0,0 +1,86 @@
// This file is merely types only, you (probably) want to put the tests in ./serve-types.test.ts instead
import { expectType } from "./utilities";
Bun.serve({
routes: {
"/:id/:test": req => {
expectType(req.params).is<{ id: string; test: string }>();
},
},
fetch: () => new Response("hello"),
websocket: {
message(ws, message) {
expectType(ws.data).is<undefined>();
expectType(message).is<string | Buffer<ArrayBuffer>>();
},
},
});
const s1 = Bun.serve({
routes: {
"/ws/:name": req => {
expectType(req.params.name).is<string>();
s1.upgrade(req, {
data: { name: req.params.name },
});
},
},
websocket: {
data: {} as { name: string },
message(ws) {
ws.send(JSON.stringify(ws.data));
},
},
});
const s2 = Bun.serve({
routes: {
"/ws/:name": req => {
expectType(req.params.name).is<string>();
// @ts-expect-error - Should error because data was not passed
s2.upgrade(req, {});
},
},
websocket: {
data: {} as { name: string },
message(ws) {
expectType(ws.data).is<{ name: string }>();
},
},
});
const s3 = Bun.serve({
routes: {
"/ws/:name": req => {
expectType(req.params.name).is<string>();
// @ts-expect-error - Should error because data and object was not passed
s3.upgrade(req);
},
},
websocket: {
data: {} as { name: string },
message(ws) {
expectType(ws.data).is<{ name: string }>();
},
},
});
const s4 = Bun.serve({
routes: {
"/ws/:name": req => {
expectType(req.params.name).is<string>();
s4.upgrade(req);
},
},
websocket: {
message(ws) {
expectType(ws.data).is<undefined>();
},
},
});