From 5b25a3abdb63ea670f02a8be9f38e80d29e06d01 Mon Sep 17 00:00:00 2001 From: robobun Date: Thu, 15 Jan 2026 20:28:32 -0800 Subject: [PATCH] fix: don't call Bun.serve() on exported Server instances (#26144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes the entry point wrapper to distinguish between Server configuration objects and already-running Server instances - When a Server object from `Bun.serve()` is exported as the default export, Bun no longer tries to call `Bun.serve()` on it again ## Root Cause The entry point wrapper in `src/bundler/entry_points.zig` checks if the default export has a `fetch` method to auto-start servers: ```javascript if (typeof entryNamespace?.default?.fetch === 'function' || ...) { const server = Bun.serve(entryNamespace.default); } ``` However, `Server` objects returned from `Bun.serve()` also have a `fetch` method (for programmatic request handling), so the wrapper mistakenly tried to call `Bun.serve(server)` on an already-running server. ## Solution Added an `isServerConfig()` helper that checks: 1. The object has a `fetch` function or `app` property (config object indicators) 2. The object does NOT have a `stop` method (Server instance indicator) Server instances have `stop`, `reload`, `upgrade`, etc. methods, while config objects don't. ## Test plan - [x] Added regression test that verifies exporting a Server as default export works without errors - [x] Added test that verifies config objects with `fetch` still trigger auto-start - [x] Verified test fails with `USE_SYSTEM_BUN=1` and passes with the fix Fixes #26142 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot Co-authored-by: Claude Opus 4.5 --- src/bundler/entry_points.zig | 14 +++-- test/regression/issue/26142.test.ts | 96 +++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 test/regression/issue/26142.test.ts diff --git a/src/bundler/entry_points.zig b/src/bundler/entry_points.zig index fdf2df2d1e..8e0db5e6b2 100644 --- a/src/bundler/entry_points.zig +++ b/src/bundler/entry_points.zig @@ -166,10 +166,13 @@ pub const ServerEntryPoint = struct { \\import * as start from '{f}'; \\var hmrSymbol = Symbol("BunServerHMR"); \\var entryNamespace = start; + \\function isServerConfig(def) {{ + \\ return def && def !== globalThis && (typeof def.fetch === 'function' || def.app != undefined) && typeof def.stop !== 'function'; + \\}} \\if (typeof entryNamespace?.then === 'function') {{ \\ entryNamespace = entryNamespace.then((entryNamespace) => {{ \\ var def = entryNamespace?.default; - \\ if (def && def !== globalThis && (typeof def.fetch === 'function' || def.app != undefined)) {{ + \\ if (isServerConfig(def)) {{ \\ var server = globalThis[hmrSymbol]; \\ if (server) {{ \\ server.reload(def); @@ -180,7 +183,7 @@ pub const ServerEntryPoint = struct { \\ }} \\ }} \\ }}, reportError); - \\}} else if (entryNamespace?.default !== globalThis && (typeof entryNamespace?.default?.fetch === 'function' || entryNamespace?.default?.app != undefined)) {{ + \\}} else if (isServerConfig(entryNamespace?.default)) {{ \\ var server = globalThis[hmrSymbol]; \\ if (server) {{ \\ server.reload(entryNamespace.default); @@ -202,14 +205,17 @@ pub const ServerEntryPoint = struct { \\// @bun \\import * as start from "{f}"; \\var entryNamespace = start; + \\function isServerConfig(def) {{ + \\ return def && def !== globalThis && (typeof def.fetch === 'function' || def.app != undefined) && typeof def.stop !== 'function'; + \\}} \\if (typeof entryNamespace?.then === 'function') {{ \\ entryNamespace = entryNamespace.then((entryNamespace) => {{ - \\ if (entryNamespace?.default !== globalThis && typeof entryNamespace?.default?.fetch === 'function') {{ + \\ if (isServerConfig(entryNamespace?.default)) {{ \\ const server = Bun.serve(entryNamespace.default); \\ console.debug(`Started ${{server.development ? 'development ' : ''}}server: ${{server.protocol}}://${{server.hostname}}:${{server.port}}`); \\ }} \\ }}, reportError); - \\}} else if (entryNamespace?.default !== globalThis && (typeof entryNamespace?.default?.fetch === 'function' || entryNamespace?.default?.app != null)) {{ + \\}} else if (isServerConfig(entryNamespace?.default)) {{ \\ const server = Bun.serve(entryNamespace.default); \\ console.debug(`Started ${{server.development ? 'development ' : ''}}server: ${{server.protocol}}://${{server.hostname}}:${{server.port}}`); \\}} diff --git a/test/regression/issue/26142.test.ts b/test/regression/issue/26142.test.ts new file mode 100644 index 0000000000..0485474df4 --- /dev/null +++ b/test/regression/issue/26142.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/26142 +// When exporting a Server object from Bun.serve() as the default export, +// Bun's entry point wrapper should not try to call Bun.serve() on it again. + +test("exporting server as default export should not error", async () => { + using dir = tempDir("issue-26142", { + "server.js": ` +const server = Bun.serve({ + port: 0, + routes: { + "/": { GET: () => Response.json({ message: "Hello" }) }, + }, + fetch(req) { + return Response.json({ error: "Not Found" }, { status: 404 }); + }, +}); + +console.log("Server running on port " + server.port); + +// Stop the server immediately so the process can exit +server.stop(); + +// This export was causing the issue - entry point wrapper would try to +// call Bun.serve() on the already-running server +export default server; +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should not have any errors related to double-serving + expect(stderr).not.toContain("EADDRINUSE"); + expect(stderr).not.toContain("Maximum call stack"); + expect(stderr).not.toContain("is already listening"); + + // Check stdout for the expected message + expect(stdout).toContain("Server running on port"); + + // Process should exit successfully + expect(exitCode).toBe(0); +}); + +test("server config with fetch as default export should still auto-start", async () => { + using dir = tempDir("issue-26142-config", { + "server.js": ` +// Export a config object (not a server instance) +// This should still trigger auto-start +export default { + port: 0, + fetch(req) { + return Response.json({ working: true }); + }, +}; +`, + }); + + const proc = Bun.spawn({ + cmd: [bunExe(), "server.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + // Set a timeout to kill the server after checking output + const timeout = setTimeout(() => proc.kill(), 3000); + + try { + // Wait for first bit of stdout to verify server started + const reader = proc.stdout.getReader(); + const { value } = await reader.read(); + reader.releaseLock(); + + // Decode the output + const decoder = new TextDecoder(); + const output = decoder.decode(value); + + // Should have started the server (look for the debug message on stdout) + expect(output).toContain("Started"); + } finally { + clearTimeout(timeout); + proc.kill(); + await proc.exited; + } +});