Merge remote-tracking branch 'origin/main' into ali/react

This commit is contained in:
Alistair Smith
2025-10-06 16:24:19 -07:00
17 changed files with 1798 additions and 1502 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" />
/// <reference path="./app.d.ts" />

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

File diff suppressed because it is too large Load Diff

View File

@@ -2677,7 +2677,9 @@ pub fn remapZigException(
}
// Workaround for being unable to hide that specific frame without also hiding the frame before it
if (frame.source_url.isEmpty() and NoisyBuiltinFunctionMap.getWithEql(frame.function_name, String.eqlComptime) != null) {
if ((frame.source_url.isEmpty() or frame.source_url.eqlComptime("[unknown]") or frame.source_url.hasPrefixComptime("[source:")) and
NoisyBuiltinFunctionMap.getWithEql(frame.function_name, String.eqlComptime) != null)
{
start_index = 0;
break;
}
@@ -2693,7 +2695,9 @@ pub fn remapZigException(
}
// Workaround for being unable to hide that specific frame without also hiding the frame before it
if (frame.source_url.isEmpty() and NoisyBuiltinFunctionMap.getWithEql(frame.function_name, String.eqlComptime) != null) {
if ((frame.source_url.isEmpty() or frame.source_url.eqlComptime("[unknown]") or frame.source_url.hasPrefixComptime("[source:")) and
NoisyBuiltinFunctionMap.getWithEql(frame.function_name, String.eqlComptime) != null)
{
continue;
}
@@ -2715,7 +2719,9 @@ pub fn remapZigException(
frame.source_url.hasPrefixComptime("node:") or
frame.source_url.isEmpty() or
frame.source_url.eqlComptime("native") or
frame.source_url.eqlComptime("unknown"))
frame.source_url.eqlComptime("unknown") or
frame.source_url.eqlComptime("[unknown]") or
frame.source_url.hasPrefixComptime("[source:"))
{
top_frame_is_builtin = true;
continue;

View File

@@ -437,7 +437,22 @@ ALWAYS_INLINE String JSCStackFrame::retrieveSourceURL()
}
}
return String();
// BUGFIX: Don't return empty string which breaks the 'bindings' npm package
// The bindings package uses Error.prepareStackTrace to find the calling module
// but empty filenames cause it to use the wrong module root directory
// Instead, try to get some identifying information for this frame
// Try to use sourceID if available
if (m_codeBlock) {
auto sourceID = m_codeBlock->ownerExecutable()->sourceID();
if (sourceID != JSC::noSourceID) {
// Use a placeholder that includes the sourceID to make frames distinguishable
return makeString("[source:"_s, sourceID, "]"_s);
}
}
// Last resort: return a distinguishable placeholder instead of empty string
return "[unknown]"_s;
}
ALWAYS_INLINE String JSCStackFrame::retrieveFunctionName()

View File

@@ -452,7 +452,8 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtr<JSC::Excepti
}
auto& stack_frame = e_stack[0];
auto source_url = stack_frame.sourceURL(vm);
if (source_url.isEmpty()) {
// Treat empty, [unknown], and [source:*] placeholders as missing source URLs
if (source_url.isEmpty() || source_url == "[unknown]"_s || source_url.startsWith("[source:"_s)) {
// copy what Node does: https://github.com/nodejs/node/blob/afe3909483a2d5ae6b847055f544da40571fb28d/lib/vm.js#L94
source_url = "evalmachine.<anonymous>"_s;
}

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>();
},
},
});

View File

@@ -0,0 +1,78 @@
import { expect, test } from "bun:test";
test("Error.prepareStackTrace should not have empty filenames", () => {
let capturedStack: any[] = [];
const originalPrepareStackTrace = Error.prepareStackTrace;
try {
Error.prepareStackTrace = function (error, stack) {
capturedStack = stack;
return error.toString();
};
// Create an error to capture the stack trace
const err = new Error("test");
err.stack; // Trigger prepareStackTrace
// Verify that all frames have non-empty filenames
for (const frame of capturedStack) {
const filename = frame.getFileName();
// The filename should never be an empty string
// It can be null/undefined, or a meaningful value like "[unknown]" or a file path
if (filename !== null && filename !== undefined) {
expect(filename).not.toBe("");
}
}
} finally {
Error.prepareStackTrace = originalPrepareStackTrace;
}
});
test("bindings package use case: finding caller module", () => {
let capturedStack: any[] = [];
const originalPrepareStackTrace = Error.prepareStackTrace;
try {
Error.prepareStackTrace = function (error, stack) {
capturedStack = stack;
return error.toString();
};
function simulateBindingsPackageLogic() {
const err = new Error();
err.stack; // Trigger prepareStackTrace
// This simulates what the bindings package does
// It looks for the first non-empty filename to determine the calling module
let callerFile = null;
for (const frame of capturedStack) {
const filename = frame.getFileName();
if (filename && filename !== "") {
callerFile = filename;
break;
}
}
return callerFile;
}
const result = simulateBindingsPackageLogic();
// The bindings package should be able to find a caller file
// It should not get confused by empty strings
expect(result).toBeTruthy();
// Verify none of the filenames are empty strings
for (const frame of capturedStack) {
const filename = frame.getFileName();
if (filename !== null && filename !== undefined) {
expect(filename).not.toBe("");
}
}
} finally {
Error.prepareStackTrace = originalPrepareStackTrace;
}
});