Skip to content
Merged
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
4 changes: 2 additions & 2 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@

### 🚀 Improvements

- Support reading WHATWG `ReadableStream` (web streams) - by [@bjohansebas](https://github.com/bjohansebas) in [#148](https://github.com/stream-utils/raw-body/pull/148) and [#160](https://github.com/stream-utils/raw-body/pull/160)
- Support reading WHATWG `ReadableStream` (web streams) through the new `getRawBodyWeb` export - by [@bjohansebas](https://github.com/bjohansebas) in [#148](https://github.com/stream-utils/raw-body/pull/148), [#160](https://github.com/stream-utils/raw-body/pull/160) and [#175](https://github.com/stream-utils/raw-body/pull/175)

`fetch` `Request`/`Response` bodies, `Blob.stream()`, `TransformStream` readables, and `Readable.toWeb()` bridges can be read directly. Streams already locked, read, or cancelled error with a 500 `stream.not.readable`; client aborts are mapped to the same 400 `request.aborted` error as node streams, with the original error in `cause`; string chunks are accepted without an encoding (UTF-8 `Buffer`), but error with a 500 `stream.encoding.set` when combined with one, since the stream is already decoded; non-byte chunks (e.g. `ArrayBuffer`) error with a `TypeError`. On error the reader lock is released, but the stream is not cancelled; disposing it is up to the caller.
`fetch` `Request`/`Response` bodies, `Blob.stream()`, `TransformStream` readables, and `Readable.toWeb()` bridges can be read with `getRawBodyWeb`, which takes the same options as `getRawBody`; `getRawBody` itself keeps accepting only node streams. Streams already locked, read, or cancelled error with a 500 `stream.not.readable`; client aborts are mapped to the same 400 `request.aborted` error as node streams, with the original error in `cause`; string chunks are accepted without an encoding (UTF-8 `Buffer`), but error with a 500 `stream.encoding.set` when combined with one, since the stream is already decoded; non-byte chunks (e.g. `ArrayBuffer`) error with a `TypeError`. On error the reader lock is released, but the stream is not cancelled; disposing it is up to the caller.

- Add a custom `decoder` option - by [@bjohansebas](https://github.com/bjohansebas) in [#145](https://github.com/stream-utils/raw-body/pull/145)

Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ npm install raw-body
## API

```js
import getRawBody from 'raw-body'
import getRawBody, { getRawBodyWeb } from 'raw-body'
```

The package is ESM-only. CommonJS consumers can load it with
Expand All @@ -32,16 +32,27 @@ available in all supported Node.js versions:

```js
const getRawBody = require('raw-body').default
const { getRawBodyWeb } = require('raw-body')
```

### getRawBody(stream, [options], [callback])

**Returns a promise if no callback specified.**

The `stream` argument can be a Node.js readable stream (like an HTTP request)
or a [WHATWG `ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
The `stream` argument must be a Node.js readable stream
(like an HTTP request).

### getRawBodyWeb(stream, [options], [callback])

**Returns a promise if no callback specified.**

The `stream` argument must be a
[WHATWG `ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
(like the body of a `fetch` `Response`).

Both functions accept the same options and behave the same, they only
differ in the kind of stream they read.

Options:

- `length` - The length of the stream.
Expand Down Expand Up @@ -82,7 +93,7 @@ getRawBody(stream, {

You can also pass a string in place of options to just specify the encoding.

If an error occurs, the stream will be paused, everything unpiped,
For node streams, if an error occurs, the stream will be paused, everything unpiped,
and you are responsible for correctly disposing the stream.
For HTTP requests, you may need to finish consuming the stream if
you want to keep the socket open for future requests. For streams
Expand Down
6 changes: 3 additions & 3 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,17 @@ app.use(function * (next) {
## Simple Hono example

Hono's request is a `fetch` `Request`, so its body is a WHATWG
`ReadableStream` and can be read directly:
`ReadableStream` and can be read with `getRawBodyWeb`:

```js
import { Hono } from 'hono'
import getRawBody from 'raw-body'
import { getRawBodyWeb } from 'raw-body'

const app = new Hono()

app.post('/', async (c) => {
try {
const text = await getRawBody(c.req.raw.body, {
const text = await getRawBodyWeb(c.req.raw.body, {
length: c.req.header('content-length'),
limit: '1mb',
encoding: 'utf-8'
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"size-limit": [
{
"path": "dist/index.js",
"limit": "4.61 KB",
"limit": "4.56 KB",
"ignore": [
"node:async_hooks",
"node:stream"
Expand Down
162 changes: 106 additions & 56 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export type Encoding = string | true
/**
* The stream types accepted by `getRawBody`.
*/
export type RawBodyStream = NodeJS.ReadableStream | Readable | ReadableStream<Uint8Array | string>
export type NodeRawBodyStream = NodeJS.ReadableStream | Readable

/**
* The stream types accepted by `getRawBodyWeb`.
*/
export type WebRawBodyStream = ReadableStream<Uint8Array | string>

/**
* A streaming decoder, turning body chunks into a string.
Expand Down Expand Up @@ -259,51 +264,20 @@ function finish (decoder: Decoder | null, buffer: string | Buffer[], length: num
}

/**
* Gets the entire buffer of a stream as a `Buffer`, delivered to the
* callback. Validates the stream's length against an expected length
* and maximum limit. Ideal for parsing request bodies.
*/
function getRawBody (stream: RawBodyStream, callback: Callback<Buffer>): void
/**
* Gets the entire buffer of a stream decoded as a string with the
* given encoding, delivered to the callback. Validates the stream's
* length against an expected length and maximum limit. Ideal for
* parsing request bodies.
*/
function getRawBody (stream: RawBodyStream, options: Readonly<Options & { encoding: Encoding }> | Encoding, callback: Callback<string>): void
/**
* Gets the entire buffer of a stream as a `Buffer`, delivered to the
* callback. Validates the stream's length against an expected length
* and maximum limit. Ideal for parsing request bodies.
* The reader for a stream type: `readStream` or `readWebStream`.
*/
function getRawBody (stream: RawBodyStream, options: Readonly<Options> | null, callback: Callback<Buffer>): void
/**
* Gets the entire buffer of a stream decoded as a string with the
* given encoding. Validates the stream's length against an expected
* length and maximum limit. Ideal for parsing request bodies.
*/
function getRawBody (stream: RawBodyStream, options: Readonly<Options & { encoding: Encoding }> | Encoding): Promise<string>
type ReadBody<S> = (stream: S, encoding: string | null, length: number | null, limit: number | null, createDecoder: CreateDecoder | undefined, callback: InternalCallback) => void

/**
* Gets the entire buffer of a stream as a `Buffer`. Validates the
* stream's length against an expected length and maximum limit.
* Ideal for parsing request bodies.
* Validate the options and read the stream, shared by `getRawBody`
* and `getRawBodyWeb`. The reader is passed in, without allocating
* an intermediate closure per call.
*/
function getRawBody (stream: RawBodyStream, options?: Readonly<Options> | null): Promise<Buffer>
function getRawBody (stream: RawBodyStream, options?: Readonly<Options> | Encoding | Callback<Buffer> | null, callback?: Callback<Buffer> | Callback<string>): Promise<Buffer | string> | void {

function getBody<S> (read: ReadBody<S>, stream: S, options: Readonly<Options> | Encoding | Callback<Buffer> | null | undefined, callback: Callback<Buffer> | Callback<string> | undefined): Promise<Buffer | string> | void {
let done = callback as InternalCallback | undefined
let opts: Readonly<Options> = (options || {}) as Options

// light validation.
if (stream === undefined) {
throw new TypeError('argument stream is required')
}

const nodeReadable = typeof stream === 'object' && stream !== null && isNodeReadable(stream)

if (!nodeReadable && (typeof stream !== 'object' || stream === null || !isWebReadable(stream))) {
throw new TypeError('argument stream must be a stream')
}

if (options === true || typeof options === 'string') {
// short cut for encoding
opts = {
Expand Down Expand Up @@ -346,36 +320,112 @@ function getRawBody (stream: RawBodyStream, options?: Readonly<Options> | Encodi
: NaN
const length = Number.isNaN(parsedLength) ? null : parsedLength

// dispatch to the reader for the stream type, without allocating an
// intermediate closure per call. node streams take precedence.
const decoder = opts.decoder

if (done) {
// classic callback style
const bound = bindAsyncContext(done)
if (nodeReadable) {
readStream(stream, encoding, length, limit, decoder, bound)
} else {
readWebStream(stream, encoding, length, limit, decoder, bound)
}
read(stream, encoding, length, limit, decoder, bindAsyncContext(done))
return
}

return new Promise(function executor (resolve, reject) {
const onRead: InternalCallback = function onRead (err, buf) {
read(stream, encoding, length, limit, decoder, function onRead (err, buf) {
if (err) return reject(err)
resolve(buf as Buffer | string)
}
if (nodeReadable) {
readStream(stream, encoding, length, limit, decoder, onRead)
} else {
readWebStream(stream, encoding, length, limit, decoder, onRead)
}
})
})
}

/**
* Gets the entire buffer of a node stream as a `Buffer`, delivered to
* the callback. Validates the stream's length against an expected
* length and maximum limit. Ideal for parsing request bodies.
*/
function getRawBody (stream: NodeRawBodyStream, callback: Callback<Buffer>): void
/**
* Gets the entire buffer of a node stream decoded as a string with the
* given encoding, delivered to the callback. Validates the stream's
* length against an expected length and maximum limit. Ideal for
* parsing request bodies.
*/
function getRawBody (stream: NodeRawBodyStream, options: Readonly<Options & { encoding: Encoding }> | Encoding, callback: Callback<string>): void
/**
* Gets the entire buffer of a node stream as a `Buffer`, delivered to
* the callback. Validates the stream's length against an expected
* length and maximum limit. Ideal for parsing request bodies.
*/
function getRawBody (stream: NodeRawBodyStream, options: Readonly<Options> | null, callback: Callback<Buffer>): void
/**
* Gets the entire buffer of a node stream decoded as a string with the
* given encoding. Validates the stream's length against an expected
* length and maximum limit. Ideal for parsing request bodies.
*/
function getRawBody (stream: NodeRawBodyStream, options: Readonly<Options & { encoding: Encoding }> | Encoding): Promise<string>
/**
* Gets the entire buffer of a node stream as a `Buffer`. Validates the
* stream's length against an expected length and maximum limit.
* Ideal for parsing request bodies.
*/
function getRawBody (stream: NodeRawBodyStream, options?: Readonly<Options> | null): Promise<Buffer>
function getRawBody (stream: NodeRawBodyStream, options?: Readonly<Options> | Encoding | Callback<Buffer> | null, callback?: Callback<Buffer> | Callback<string>): Promise<Buffer | string> | void {
// light validation.
if (stream === undefined) {
throw new TypeError('argument stream is required')
}

if (typeof stream !== 'object' || stream === null || !isNodeReadable(stream)) {
throw new TypeError('argument stream must be a node stream')
}

return getBody(readStream, stream, options, callback)
}

/**
* Gets the entire buffer of a web `ReadableStream` as a `Buffer`,
* delivered to the callback. Validates the stream's length against an
* expected length and maximum limit. Ideal for parsing request bodies.
*/
function getRawBodyWeb (stream: WebRawBodyStream, callback: Callback<Buffer>): void
/**
* Gets the entire buffer of a web `ReadableStream` decoded as a string
* with the given encoding, delivered to the callback. Validates the
* stream's length against an expected length and maximum limit. Ideal
* for parsing request bodies.
*/
function getRawBodyWeb (stream: WebRawBodyStream, options: Readonly<Options & { encoding: Encoding }> | Encoding, callback: Callback<string>): void
/**
* Gets the entire buffer of a web `ReadableStream` as a `Buffer`,
* delivered to the callback. Validates the stream's length against an
* expected length and maximum limit. Ideal for parsing request bodies.
*/
function getRawBodyWeb (stream: WebRawBodyStream, options: Readonly<Options> | null, callback: Callback<Buffer>): void
/**
* Gets the entire buffer of a web `ReadableStream` decoded as a string
* with the given encoding. Validates the stream's length against an
* expected length and maximum limit. Ideal for parsing request bodies.
*/
function getRawBodyWeb (stream: WebRawBodyStream, options: Readonly<Options & { encoding: Encoding }> | Encoding): Promise<string>
/**
* Gets the entire buffer of a web `ReadableStream` as a `Buffer`.
* Validates the stream's length against an expected length and
* maximum limit. Ideal for parsing request bodies.
*/
function getRawBodyWeb (stream: WebRawBodyStream, options?: Readonly<Options> | null): Promise<Buffer>
function getRawBodyWeb (stream: WebRawBodyStream, options?: Readonly<Options> | Encoding | Callback<Buffer> | null, callback?: Callback<Buffer> | Callback<string>): Promise<Buffer | string> | void {
// light validation.
if (stream === undefined) {
throw new TypeError('argument stream is required')
}

if (typeof stream !== 'object' || stream === null || !isWebReadable(stream)) {
throw new TypeError('argument stream must be a web ReadableStream')
}

return getBody(readWebStream, stream, options, callback)
}

export default getRawBody
export { getRawBody }
export { getRawBody, getRawBodyWeb }

/**
* Halt a stream.
Expand Down
6 changes: 3 additions & 3 deletions test/flowing.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert'
import { Readable, Writable } from 'node:stream'
import { describe, it } from 'vitest'
import getRawBody from '../src/index.ts'
import getRawBody, { getRawBodyWeb } from '../src/index.ts'
import { withDone } from './support/with-done.ts'

const defaultLimit = 1024 * 1024
Expand Down Expand Up @@ -114,7 +114,7 @@ describe('stream flowing', function () {
it('should stop pulling', withDone(function (done) {
const stream = createInfiniteWebStream()

getRawBody(stream, {
getRawBodyWeb(stream, {
limit: defaultLimit
}, function (err) {
assert.ok(err)
Expand All @@ -129,7 +129,7 @@ describe('stream flowing', function () {
it('should not pull when length exceeds limit', withDone(function (done) {
const stream = createInfiniteWebStream()

getRawBody(stream, {
getRawBodyWeb(stream, {
length: defaultLimit * 2,
limit: defaultLimit
}, function (err) {
Expand Down
8 changes: 4 additions & 4 deletions test/index.bench.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Readable } from 'node:stream'
import { bench, describe } from 'vitest'
import getRawBody from '../src/index.ts'
import getRawBody, { getRawBodyWeb } from '../src/index.ts'

/**
* Compares the node stream path against the web stream path with
Expand Down Expand Up @@ -73,7 +73,7 @@ for (const scenario of scenarios) {
})

bench('web stream', async () => {
await getRawBody(webStream(chunks), options)
await getRawBodyWeb(webStream(chunks), options)
})
})

Expand All @@ -83,7 +83,7 @@ for (const scenario of scenarios) {
})

bench('web stream', async () => {
await getRawBody(webStream(chunks), stringOptions)
await getRawBodyWeb(webStream(chunks), stringOptions)
})
})

Expand All @@ -97,7 +97,7 @@ for (const scenario of scenarios) {
})

bench('web stream', async () => {
await getRawBody(webStream(chunks), noLengthOptions)
await getRawBodyWeb(webStream(chunks), noLengthOptions)
})
})
}
27 changes: 7 additions & 20 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ describe('Raw Body', function () {
// @ts-expect-error missing stream argument
assert.throws(function () { getRawBody() }, /argument stream is required/)
// @ts-expect-error invalid stream argument
assert.throws(function () { getRawBody(null) }, /argument stream must be a stream/)
assert.throws(function () { getRawBody(null) }, /argument stream must be a node stream/)
// @ts-expect-error invalid stream argument
assert.throws(function () { getRawBody(42) }, /argument stream must be a stream/)
assert.throws(function () { getRawBody(42) }, /argument stream must be a node stream/)
// @ts-expect-error invalid stream argument
assert.throws(function () { getRawBody('str') }, /argument stream must be a stream/)
assert.throws(function () { getRawBody('str') }, /argument stream must be a node stream/)
// @ts-expect-error invalid stream argument
assert.throws(function () { getRawBody({}) }, /argument stream must be a stream/)
assert.throws(function () { getRawBody({}) }, /argument stream must be a node stream/)
// a web ReadableStream belongs to getRawBodyWeb
// @ts-expect-error invalid stream argument
assert.throws(function () { getRawBody(new ReadableStream()) }, /argument stream must be a node stream/)
})

it('should work without any options', withDone(function (done) {
Expand Down Expand Up @@ -332,22 +335,6 @@ describe('Raw Body', function () {
})
}))

it('should prefer the node stream interface when both are present', withDone(function (done) {
const stream = createStream(Buffer.from('hello, world!')) as Readable & { getReader?: () => never }

// a wrapper library may decorate a node stream with a
// web-stream compatibility shim; the node path must win
stream.getReader = function () {
throw new Error('getReader should not be called')
}

getRawBody(stream, { encoding: true }, function (err, str) {
assert.ifError(err)
assert.strictEqual(str, 'hello, world!')
done()
})
}))

it('should not invoke the callback synchronously on early errors', withDone(function (done) {
let returned = false

Expand Down
Loading
Loading