Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"prepare": "simple-git-hooks",
"type:check": "pnpm -r run type:check && tsc",
"test": "pnpm run -r test && vitest run",
"test:coverage": "vitest run --coverage",
"test:coverage": "pnpm run -r test && vitest run --coverage",
"bench": "vitest bench --run",
"lint": "eslint --max-warnings=0 .",
"lint:fix": "eslint --max-warnings=0 --fix .",
Expand Down
63 changes: 62 additions & 1 deletion packages/core/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { flattenStandardHeader, generateContentDisposition, getFilenameFromContentDisposition, mergeStandardHeaders, parseStandardUrl } from './utils'
import { flattenStandardHeader, generateContentDisposition, getFilenameFromContentDisposition, mergeStandardHeaders, parseStandardUrl, resolveStandardBodyHint } from './utils'

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -75,6 +75,67 @@ it('getFilenameFromContentDisposition', () => {
expect(getFilenameFromContentDisposition('attachment; filename*=iso-8859-1\'\'%E9.txt')).toEqual(undefined)
})

describe('resolveStandardBodyHint', () => {
it('standard-server header wins over content headers', () => {
expect(resolveStandardBodyHint({ 'standard-server': 'none', 'content-length': '3', 'content-type': 'application/pdf' })).toBe('none')
expect(resolveStandardBodyHint({ 'standard-server': 'json', 'content-length': '3', 'content-type': 'application/pdf' })).toBe('json')
expect(resolveStandardBodyHint({ 'standard-server': ['file'], 'content-type': 'application/json' })).toBe('file')
expect(resolveStandardBodyHint({ 'standard-server': 'octet-stream', 'content-length': '3' })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'standard-server': 'event-stream', 'content-length': '3', 'content-type': 'application/json' })).toBe('event-stream')
expect(resolveStandardBodyHint({ 'standard-server': 'form-data', 'content-length': '3', 'content-type': 'application/json' })).toBe('form-data')
expect(resolveStandardBodyHint({ 'standard-server': 'url-search-params', 'content-length': '3', 'content-type': 'application/json' })).toBe('url-search-params')
})

it('ignores an unrecognized standard-server header', () => {
expect(resolveStandardBodyHint({ 'standard-server': 'invalid', 'content-type': 'application/json' })).toBe('json')
expect(resolveStandardBodyHint({ 'standard-server': '', 'content-length': '3' })).toBe('file')
// a repeated header flattens to a comma-joined value, which is not a hint
expect(resolveStandardBodyHint({ 'standard-server': ['file', 'json'] })).toBe('none')
})

it('falls back to content headers when the standard-server header is unset', () => {
expect(resolveStandardBodyHint({ 'standard-server': undefined, 'content-length': '3' })).toBe('file')
// an empty array is the unset-header convention
expect(resolveStandardBodyHint({ 'standard-server': [], 'content-length': '3' })).toBe('file')
})

it('none when no content-type and no meaningful content-length', () => {
expect(resolveStandardBodyHint({})).toBe('none')
expect(resolveStandardBodyHint({ 'content-length': '0' })).toBe('none')
expect(resolveStandardBodyHint({ 'content-type': [], 'content-length': [] })).toBe('none')
})

it('by content-type', () => {
expect(resolveStandardBodyHint({ 'content-type': 'application/json' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': 'application/json; charset=utf-8' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': ' application/json ' })).toBe('json')
expect(resolveStandardBodyHint({ 'content-type': 'multipart/form-data; boundary=x' })).toBe('form-data')
expect(resolveStandardBodyHint({ 'content-type': 'application/x-www-form-urlencoded' })).toBe('url-search-params')
expect(resolveStandardBodyHint({ 'content-type': 'text/event-stream' })).toBe('event-stream')

// content-type wins over content-length
expect(resolveStandardBodyHint({ 'content-type': 'application/json', 'content-length': '3' })).toBe('json')
})

it('file when content-length is present', () => {
expect(resolveStandardBodyHint({ 'content-length': '3' })).toBe('file')
expect(resolveStandardBodyHint({ 'content-length': ['3'] })).toBe('file')
expect(resolveStandardBodyHint({ 'content-length': '3', 'content-type': 'application/pdf' })).toBe('file')
// an empty file is still a file when a content-type is present
expect(resolveStandardBodyHint({ 'content-length': '0', 'content-type': 'application/pdf' })).toBe('file')
// an empty content-type is a valid content-type, not an absent one
expect(resolveStandardBodyHint({ 'content-length': '0', 'content-type': '' })).toBe('file')
expect(resolveStandardBodyHint({ 'content-length': '0', 'content-type': ' ; charset=utf-8' })).toBe('file')
})

it('octet-stream when content-length is absent', () => {
expect(resolveStandardBodyHint({ 'content-type': 'application/octet-stream' })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'content-type': 'application/pdf' })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'content-type': 'text/plain', 'content-length': [] })).toBe('octet-stream')
expect(resolveStandardBodyHint({ 'content-type': '' })).toBe('octet-stream')
})
})

describe('mergeStandardHeaders', () => {
afterEach(() => {
expect(({} as any).polluted).toEqual(undefined)
Expand Down
53 changes: 52 additions & 1 deletion packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { StandardHeaders, StandardUrl } from './types'
import type { StandardBodyHint, StandardHeaders, StandardUrl } from './types'
import { toArray, tryDecodeURIComponent } from '@standardserver/shared'

export function generateContentDisposition(filename: string, type: 'inline' | 'attachment' = 'inline'): string {
Expand Down Expand Up @@ -52,6 +52,57 @@ export function flattenStandardHeader(header: string | readonly string[] | undef
return header.join(', ')
}

const STANDARD_BODY_HINT_SET: ReadonlySet<string> = new Set<StandardBodyHint>([
'json',
'form-data',
'url-search-params',
'event-stream',
'octet-stream',
'file',
'none',
])

/**
* Resolves how a receiver parses a body from the standard headers alone,
* mirroring what the body parsers do.
*/
export function resolveStandardBodyHint(headers: StandardHeaders): StandardBodyHint {
const hint = flattenStandardHeader(headers['standard-server'])

if (hint !== undefined && STANDARD_BODY_HINT_SET.has(hint)) {
return hint as StandardBodyHint
}

const mimeType = flattenStandardHeader(headers['content-type'])?.split(';')[0]?.trim()
const contentLength = flattenStandardHeader(headers['content-length'])

if (mimeType === undefined && (contentLength === undefined || contentLength === '0')) {
return 'none'
}

if (mimeType === 'application/json') {
return 'json'
}

if (mimeType === 'multipart/form-data') {
return 'form-data'
}

if (mimeType === 'application/x-www-form-urlencoded') {
return 'url-search-params'
}

if (mimeType === 'text/event-stream') {
return 'event-stream'
}

if (contentLength !== undefined) {
return 'file'
}

return 'octet-stream'
}

export function mergeStandardHeaders(a: StandardHeaders, b: StandardHeaders): StandardHeaders {
const merged = { ...a, ...b }

Expand Down
1 change: 0 additions & 1 deletion packages/fastify/src/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ describe('sendStandardResponse', () => {
'content-disposition': 'inline; filename="blob"; filename*=utf-8\'\'blob',
'content-length': '3',
'content-type': 'text/plain',
'standard-server': 'file',
'x-custom-header': 'custom-value',
})

Expand Down
37 changes: 34 additions & 3 deletions packages/fetch/src/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,13 +486,46 @@ describe('toFetchBody', () => {
'content-length': '3',
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})

expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
expect(generateContentDispositionSpy).toHaveBeenCalledWith('blob')
})

it('blob with common content-type', () => {
const blob = new Blob(['foo'], { type: 'application/json' })

generateContentDispositionSpy.mockReturnValue('__mocked__')

const [body, headers] = toFetchBody(blob, baseHeaders, {})

expect(body).toBe(blob)
expect(headers).toEqual({
'content-disposition': '__mocked__',
'content-length': '3',
'content-type': 'application/json',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})
})

it('empty blob without content-type', () => {
const blob = new Blob([])

generateContentDispositionSpy.mockReturnValue('__mocked__')

const [body, headers] = toFetchBody(blob, baseHeaders, {})

expect(body).toBe(blob)
expect(headers).toEqual({
'content-disposition': '__mocked__',
'content-length': '0',
'content-type': '',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})
})

it('file', () => {
const blob = new File(['foo'], 'foo.pdf', { type: 'application/pdf' })

Expand All @@ -506,7 +539,6 @@ describe('toFetchBody', () => {
'content-length': '3',
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})

expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
Expand All @@ -524,7 +556,6 @@ describe('toFetchBody', () => {
'content-length': '3',
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})

expect(generateContentDispositionSpy).toHaveBeenCalledTimes(0)
Expand Down
33 changes: 14 additions & 19 deletions packages/fetch/src/body.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { StandardBody, StandardBodyHint, StandardHeaders } from '@standardserver/core'
import type { ToEventStreamOptions } from './event-stream'
import { generateContentDisposition, getFilenameFromContentDisposition } from '@standardserver/core'
import { generateContentDisposition, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standardserver/core'
import { isAsyncIteratorObject, parseEmptyableJSON, stringifyJSON } from '@standardserver/shared'
import { toAsyncIteratorObject, toEventStream } from './event-stream'

Expand All @@ -23,14 +23,6 @@ export async function toStandardBody(re: Request | Response, options?: ToStandar
return undefined
}

// request.body might be null if the method is GET, HEAD, or other methods.
// WARNING: response.body over fetch is almost always a stream,
// even if the standard-server response body is undefined.
// WARNING: React Native fetch body might not exist (undefined), so we need to explicitly check for null.
if (hint === null && re.body === null) {
return undefined
}

if (re.bodyUsed) {
// native fetch error use TypeError
throw new TypeError('Failed to read body: body stream already read')
Expand Down Expand Up @@ -97,8 +89,8 @@ export function toFetchBody(
headers = { ...headers }

if (body instanceof ReadableStream) {
// Explicitly set the body hint to avoid misidentification
// when the stream is empty, the length is predictable, or the content type is common.
// Always set the body hint: the length of a stream is unknown here, but the transport
// can still send a content-length (an empty stream), which reads back as a file.
headers['standard-server'] ??= 'octet-stream' satisfies StandardBodyHint
// content-type should be set when body is present
headers['content-type'] ??= 'application/octet-stream'
Expand All @@ -107,21 +99,24 @@ export function toFetchBody(
}

if (body instanceof Blob) {
// Explicitly set the body hint to avoid misidentification
// when the file size is NaN or the content type is common.
headers['standard-server'] ??= 'file' satisfies StandardBodyHint // A File is also a Blob

headers['content-type'] = body.type
// FIX: Bun returns `undefined` for an empty File name, despite the spec requiring a string
headers['content-disposition'] ??= generateContentDisposition(body instanceof File ? body.name ?? '' : 'blob')

// BunS3 can use NaN for the size
if (!Number.isFinite(body.size)) {
return [body.stream(), headers]
const hasKnownSize = Number.isFinite(body.size)

if (hasKnownSize) {
headers['content-length'] = body.size.toString()
}

headers['content-length'] = body.size.toString()
return [body, headers]
// Only set the body hint when the headers don't already resolve to a file.
// An empty body always needs it: senders can drop the content-type or content-length if they know the body is empty (e.g. bun, deno)
if (headers['standard-server'] === undefined && (body.size === 0 || resolveStandardBodyHint(headers) !== 'file')) {
headers['standard-server'] = 'file' satisfies StandardBodyHint // A File is also a Blob
}

return [hasKnownSize ? body : body.stream(), headers]
}

headers['standard-server'] = undefined
Expand Down
37 changes: 34 additions & 3 deletions packages/node/src/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,6 @@ describe('toNodeHttpBody', () => {
'content-length': '3',
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})

expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -527,7 +526,6 @@ describe('toNodeHttpBody', () => {
'content-length': '3',
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})

expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
Expand All @@ -554,7 +552,6 @@ describe('toNodeHttpBody', () => {
'content-length': '3',
'content-type': 'application/pdf',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})

expect(generateContentDispositionSpy).toHaveBeenCalledTimes(0)
Expand All @@ -568,6 +565,40 @@ describe('toNodeHttpBody', () => {
expect(await resBlob.text()).toBe('foo')
})

it('blob with common content-type', async () => {
const blob = new Blob(['foo'], { type: 'application/json' })

generateContentDispositionSpy.mockReturnValue('__mocked__')

const [body, headers] = toNodeHttpBody(blob, baseHeaders, {})

expect(body).toBeInstanceOf(Readable)
expect(headers).toEqual({
'content-disposition': '__mocked__',
'content-length': '3',
'content-type': 'application/json',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})
})

it('empty blob without content-type', async () => {
const blob = new Blob([])

generateContentDispositionSpy.mockReturnValue('__mocked__')

const [body, headers] = toNodeHttpBody(blob, baseHeaders, {})

expect(body).toBeInstanceOf(Readable)
expect(headers).toEqual({
'content-disposition': '__mocked__',
'content-length': '0',
'content-type': '',
'x-custom-header': 'custom-value',
'standard-server': 'file',
})
})

it('file with size=nan', async () => {
const file = new File(['foo'], 'foo.pdf', { type: 'application/pdf' })
Object.defineProperty(file, 'size', { value: Number.NaN })
Expand Down
16 changes: 9 additions & 7 deletions packages/node/src/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ToEventStreamOptions } from './event-stream'
import type { NodeHttpRequest } from './types'
import { Buffer } from 'node:buffer'
import { Readable } from 'node:stream'
import { flattenStandardHeader, generateContentDisposition, getFilenameFromContentDisposition } from '@standardserver/core'
import { flattenStandardHeader, generateContentDisposition, getFilenameFromContentDisposition, resolveStandardBodyHint } from '@standardserver/core'
import { isAsyncIteratorObject, parseEmptyableJSON, stringifyJSON } from '@standardserver/shared'
import { toAsyncIteratorObject, toEventStream } from './event-stream'
import { toStandardMethod } from './method'
Expand Down Expand Up @@ -105,8 +105,8 @@ export function toNodeHttpBody(
headers = { ...headers }

if (body instanceof ReadableStream) {
// Explicitly set the body hint to avoid misidentification
// when the stream is empty, the length is predictable, or the content type is common.
// Always set the body hint: the length of a stream is unknown here, but the transport
// can still send a content-length (an empty stream), which reads back as a file.
headers['standard-server'] ??= 'octet-stream' satisfies StandardBodyHint

// content-type is required when body is present
Expand All @@ -116,10 +116,6 @@ export function toNodeHttpBody(
}

if (body instanceof Blob) {
// Explicitly set the body hint to avoid misidentification
// when the file size is NaN or the content type is common.
headers['standard-server'] ??= 'file' satisfies StandardBodyHint // A File is also a Blob

headers['content-type'] = body.type
headers['content-disposition'] ??= generateContentDisposition(body instanceof File ? body.name : 'blob')

Expand All @@ -128,6 +124,12 @@ export function toNodeHttpBody(
headers['content-length'] = body.size.toString()
}

// Only set the body hint when the headers don't already resolve to a file.
// An empty body always needs it: senders can drop the content-type or content-length if they know the body is empty (e.g. bun, deno)
if (headers['standard-server'] === undefined && (body.size === 0 || resolveStandardBodyHint(headers) !== 'file')) {
headers['standard-server'] = 'file' satisfies StandardBodyHint // A File is also a Blob
}

return [Readable.fromWeb(body.stream()), headers]
}

Expand Down
Loading
Loading