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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"devDependencies": {
"@antfu/eslint-config": "^9.2.0",
"@codspeed/vitest-plugin": "^5.7.1",
"@google-cloud/functions-framework": "^5.0.5",
"@hono/node-server": "^2.0.12",
"@remix-run/node-fetch-server": "^0.14.0",
"@standard-server/core": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ createServer(async (req, res) => {

`resolveBody(hint?)` follows the shared Standard Server resolution rules: an explicit `hint` wins, then the [`standard-server` header](https://github.com/middleapi/standard-server/blob/main/packages/core/README.md#the-standard-server-header), then inference from the content headers. See [how body parsing works](https://github.com/middleapi/standard-server/blob/main/packages/core/README.md#how-body-parsing-works) in the core README for the full algorithm.

If upstream middleware already read the request stream, the body is parsed from `req.rawBody` when it holds the raw bytes (Google Cloud Functions, Firebase), otherwise `req.body` is returned as is (e.g. from `express.json()`).

> [!TIP]
> For efficient communication, set the `standard-server` header to explicitly hint the body type, especially for file or binary streaming. For example, if you upload a file with a common `content-type` such as `application/json` but omit the `standard-server` header, the server may interpret it as JSON and parse it unexpectedly.

Expand Down
35 changes: 34 additions & 1 deletion packages/node/src/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { NodeHttpRequest } from './types'
import { Buffer } from 'node:buffer'
import http2 from 'node:http2'
import { Readable } from 'node:stream'
import { text } from 'node:stream/consumers'
import { buffer, text } from 'node:stream/consumers'
import * as StandardServerModule from '@standard-server/core'
import { toFetchHeaders } from '@standard-server/fetch'
import { isAsyncIteratorObject } from '@standard-server/shared'
Expand Down Expand Up @@ -194,6 +194,39 @@ describe('toStandardBody', () => {
expect(standardBody).toEqual({ value: 123 })
})

it('prefer raw body over parsed body', async () => {
let standardBody: any

await request(async (req: NodeHttpRequest, res: ServerResponse) => {
req.rawBody = new Uint8Array(await buffer(req))
req.body = 'foo'
standardBody = await toStandardBody(req)
res.end()
})
.post('/')
.set('standard-server', 'file')
.send(Buffer.from('foo'))

expect(standardBody).toBeInstanceOf(File)
expect(await standardBody.text()).toBe('foo')
})

it('ignore raw body that is not a Uint8Array', async () => {
let standardBody: StandardBody

await request(async (req: NodeHttpRequest, res: ServerResponse) => {
req.rawBody = await text(req)
req.body = { value: 123 }
standardBody = await toStandardBody(req)
res.end()
})
.post('/')
.set('standard-server', 'file')
.send(Buffer.from('foo'))

expect(standardBody).toEqual({ value: 123 })
})

// body-parser 1.x (express 4) assigns `{}` to every request, even the ones it leaves unread
it('ignore body assigned without consuming the stream', async () => {
let standardBody: any
Expand Down
18 changes: 10 additions & 8 deletions packages/node/src/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ export async function toStandardBody(
req: NodeHttpRequest,
options: ToStandardBodyOptions = {},
): Promise<StandardBody> {
const stream = !req.readable && req.rawBody instanceof Uint8Array ? Readable.from([req.rawBody]) : req

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an empty application/json request, the Functions Framework leaves req.body as {} while req.rawBody is a zero-length Buffer. Because rawBody is now preferred, _streamToString yields '' and parseEmptyableJSON('') returns undefined, so GCP handlers that used to receive {} now receive undefined. This edge is not covered by the PR's behavior-change notes.

Technical details
# Empty JSON body on GCF resolves to `undefined` instead of `{}`

## Affected sites
- `packages/node/src/body.ts:24` — `req.rawBody instanceof Uint8Array` is true for the framework's zero-length `Buffer`, so the raw path is taken.
- `packages/shared/src/json.ts:1` — `parseEmptyableJSON('')` returns `undefined` (by design).

## Evidence
- Probed through the real Functions Framework with `POST`, `content-type: application/json`, empty body: `req.body` is `{}`, `req.rawBody` is a zero-length `Buffer`, and `toStandardBody(req)` returns `undefined`.
- Before the change the same request returned `{}` via the `req.body !== undefined && !req.readable` branch.

## Required outcome
- Confirm whether this normalization is intended. If so, add it to the PR/README behavior-change notes; the new value matches the non-GCF path (`parseEmptyableJSON('')`), so it may be the desired behavior.

## Open questions for the human
- Preserve `{}` for an empty parsed body, or standardize on `undefined` across all adapters?


// body's already parsed by upstream framework like express, ...
if (req.body !== undefined && !req.readable) {
if (req.body !== undefined && !stream.readable) {
return req.body
}

Expand All @@ -37,29 +39,29 @@ export async function toStandardBody(
return undefined
}

if (!req.readable) {
if (!stream.readable) {
// native fetch error use TypeError
throw new TypeError('Failed to read body: body stream already read or destroyed')
}

if (hint === 'json') {
const text = await _streamToString(req)
const text = await _streamToString(stream)
return parseEmptyableJSON(text)
}

const contentType = req.headers['content-type']

if (hint === 'form-data') {
return _streamToFormData(req, contentType)
return _streamToFormData(stream, contentType)
}

if (hint === 'url-search-params') {
const text = await _streamToString(req)
const text = await _streamToString(stream)
return new URLSearchParams(text)
}

if (hint === 'event-stream') {
return toAsyncIteratorObject(req)
return toAsyncIteratorObject(stream)
}

if (hint === 'file') {
Expand All @@ -68,10 +70,10 @@ export async function toStandardBody(
? getFilenameFromContentDisposition(contentDisposition)
: undefined

return _streamToFile(req, fileName ?? 'blob', contentType ?? '')
return _streamToFile(stream, fileName ?? 'blob', contentType ?? '')
}

return toWebReadableStream(req)
return toWebReadableStream(stream)
}

export interface ToNodeHttpBodyOptions {
Expand Down
6 changes: 6 additions & 0 deletions packages/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export type NodeHttpRequest = (IncomingMessage | Http2ServerRequest) & {
* Only used once the request stream has been consumed.
*/
body?: unknown

/**
* Raw body bytes (`Uint8Array`) kept by upstream like Google Cloud Functions, preferred over `body`.
* Only used once the request stream has been consumed.
*/
rawBody?: unknown
}

export type NodeHttpResponse = ServerResponse | Http2ServerResponse
Loading
Loading