diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index e031de268c..ff01971f2a 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -1,4 +1,6 @@ import "#nitro-internal-pollyfills"; +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; import { useNitroApp } from "nitropack/runtime"; import { startScheduleRunner } from "nitropack/runtime/internal"; @@ -6,6 +8,22 @@ import wsAdapter from "crossws/adapters/bun"; const nitroApp = useNitroApp(); +// `localFetch` attaches the pre-read body as a plain property of unenv's +// `IncomingMessage` mock, which is not a readable stream. Expose it through a +// real `node:stream` Readable so that raw pass-through such as +// `fetch(url, { body: event.node.req })` works like it does with `node-server`. +// https://github.com/nitrojs/nitro/issues/4604 +nitroApp.hooks.hook("request", (event) => { + const req = event.node.req as IncomingMessage & { body?: unknown }; + if (!("__unenv__" in req)) { + return; + } + const body = toBuffer(req.body); + if (body) { + event.node.req = toReadableRequest(req, body); + } +}); + const ws = import.meta._websocket ? wsAdapter(nitroApp.h3App.websocket) : undefined; @@ -47,3 +65,51 @@ console.log(`Listening on ${server.url}...`); if (import.meta._tasks) { startScheduleRunner(); } + +function toBuffer(body: unknown): Buffer | undefined { + if (typeof body === "string") { + return Buffer.from(body); + } + if (body instanceof ArrayBuffer) { + return Buffer.from(body); + } + if (ArrayBuffer.isView(body)) { + return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + } +} + +// Request properties carried over from the mock to the readable request +const requestKeys = [ + "httpVersion", + "httpVersionMajor", + "httpVersionMinor", + "complete", + "aborted", + "method", + "url", + "headers", + "trailers", + "socket", + "connection", + "body", // keeps h3 `readRawBody` fast path working + "__unenv__", // platform context +] as const; + +function toReadableRequest( + req: IncomingMessage, + body: Buffer +): IncomingMessage { + const readable = new Readable({ + read() { + this.push(body); + this.push(null); + }, + }); + for (const key of requestKeys) { + (readable as any)[key] = (req as any)[key]; + } + Object.defineProperty(readable, "rawHeaders", { + get: () => req.rawHeaders, + }); + return readable as unknown as IncomingMessage; +} diff --git a/test/fixture/api/node-req-body.post.ts b/test/fixture/api/node-req-body.post.ts new file mode 100644 index 0000000000..c10dc6082c --- /dev/null +++ b/test/fixture/api/node-req-body.post.ts @@ -0,0 +1,13 @@ +// Consumes `event.node.req` directly as a Node.js readable stream, the same +// way `fetch(url, { body: event.node.req })` does for raw body pass-through. +export default eventHandler(async (event) => { + const readableEnded = event.node.req.readableEnded; + const chunks: Buffer[] = []; + for await (const chunk of event.node.req) { + chunks.push(chunk); + } + return { + readableEnded, + body: Buffer.concat(chunks).toString("utf8"), + }; +}); diff --git a/test/presets/bun.test.ts b/test/presets/bun.test.ts index 95745eca43..8587080a84 100644 --- a/test/presets/bun.test.ts +++ b/test/presets/bun.test.ts @@ -1,7 +1,7 @@ import { execa, execaCommandSync } from "execa"; import { getRandomPort, waitForPort } from "get-port-please"; import { resolve } from "pathe"; -import { describe } from "vitest"; +import { describe, expect, it } from "vitest"; import { setupTest, testNitro } from "../tests"; const hasBun = @@ -10,22 +10,41 @@ const hasBun = describe.runIf(hasBun)("nitro:preset:bun", async () => { const ctx = await setupTest("bun"); - testNitro(ctx, async () => { - const port = await getRandomPort(); - process.env.PORT = String(port); - const p = execa("bun", [resolve(ctx.outDir, "server/index.mjs")], { - stdio: "inherit", - }); - ctx.server = { - url: `http://127.0.0.1:${port}`, - close: () => { - // p.kill() - }, - } as any; - await waitForPort(port); - return async ({ url, ...opts }) => { - const res = await ctx.fetch(url, opts); - return res; - }; - }); + testNitro( + ctx, + async () => { + const port = await getRandomPort(); + process.env.PORT = String(port); + const p = execa("bun", [resolve(ctx.outDir, "server/index.mjs")], { + stdio: "inherit", + }); + ctx.server = { + url: `http://127.0.0.1:${port}`, + close: () => { + // p.kill() + }, + } as any; + await waitForPort(port); + return async ({ url, ...opts }) => { + const res = await ctx.fetch(url, opts); + return res; + }; + }, + (_ctx, callHandler) => { + // https://github.com/nitrojs/nitro/issues/4604 + it("exposes the request body via `event.node.req` stream", async () => { + const { status, data } = await callHandler({ + url: "/api/node-req-body", + method: "POST", + headers: { "content-type": "text/plain" }, + body: "hello-from-bun", + }); + expect(status).toBe(200); + expect(data).toMatchObject({ + readableEnded: false, + body: "hello-from-bun", + }); + }); + } + ); });