Skip to content

Commit 638768c

Browse files
gurgundayAeolun
andcommitted
feat: add support for Fetch API Response and Web ReadableStream
Co-authored-by: Bart Riepe <[email protected]>
1 parent e706142 commit 638768c

File tree

8 files changed

+176
-8
lines changed

8 files changed

+176
-8
lines changed

README.md

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,40 @@ app.get('/', (req, reply) => {
106106
await app.listen({ port: 3000 })
107107
```
108108
109+
It's also possible to pass a Fetch API `Response` object or a Web `ReadableStream`. The plugin will automatically extract the body stream from the `Response` or convert the Web stream to a Node.js `Readable` behind the scenes.
110+
111+
```js
112+
import fastify from 'fastify'
113+
114+
const app = fastify()
115+
await app.register(import('@fastify/compress'), { global: true })
116+
117+
app.get('/', async (req, reply) => {
118+
const resp = new Response('Hello from Fetch Response')
119+
reply.compress(resp)
120+
})
121+
```
122+
123+
```js
124+
app.get('/', async (req, reply) => {
125+
return new Response('Hello from Fetch Response')
126+
})
127+
```
128+
129+
```js
130+
app.get('/', (req, reply) => {
131+
const stream = new ReadableStream({
132+
start (controller) {
133+
controller.enqueue(new TextEncoder().encode('Hello from Web ReadableStream'))
134+
controller.close()
135+
}
136+
})
137+
138+
reply.header('content-type', 'text/plain')
139+
reply.compress(stream)
140+
})
141+
```
142+
109143
## Compress Options
110144
111145
### threshold
@@ -222,15 +256,24 @@ This plugin adds a `preParsing` hook to decompress the request payload based on
222256
223257
Currently, the following encoding tokens are supported:
224258
225-
1. `zstd` (Node.js 22.15+/23.8+)
226-
2. `br`
227-
3. `gzip`
228-
4. `deflate`
259+
- `zstd` (Node.js 22.15+/23.8+)
260+
- `br`
261+
- `gzip`
262+
- `deflate`
229263
230264
If an unsupported encoding or invalid payload is received, the plugin throws an error.
231265
232266
If the request header is missing, the plugin yields to the next hook.
233267
268+
### Supported payload types
269+
270+
The plugin supports compressing the following payload types:
271+
272+
- Strings and Buffers
273+
- Node.js streams
274+
- Response objects (from the Fetch API)
275+
- ReadableStream objects (from the Web Streams API)
276+
234277
### Global hook
235278
236279
The global request decompression hook is enabled by default. To disable it, pass `{ global: false }`:

index.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const { Minipass } = require('minipass')
1212
const pumpify = require('pumpify')
1313
const { Readable } = require('readable-stream')
1414

15-
const { isStream, isGzip, isDeflate, intoAsyncIterator } = require('./lib/utils')
15+
const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } = require('./lib/utils')
1616

1717
const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
1818
const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
@@ -254,6 +254,15 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
254254
if (payload == null) {
255255
return next()
256256
}
257+
258+
if (isFetchResponse(payload)) {
259+
payload = payload.body
260+
}
261+
262+
if (isWebReadableStream(payload)) {
263+
payload = webStreamToNodeReadable(payload)
264+
}
265+
257266
const responseEncoding = reply.getHeader('Content-Encoding')
258267
if (responseEncoding && responseEncoding !== 'identity') {
259268
// response is already compressed
@@ -376,6 +385,14 @@ function compress (params) {
376385
return
377386
}
378387

388+
if (isFetchResponse(payload)) {
389+
payload = payload.body
390+
}
391+
392+
if (isWebReadableStream(payload)) {
393+
payload = webStreamToNodeReadable(payload)
394+
}
395+
379396
let stream, encoding
380397
const noCompress =
381398
// don't compress on x-no-compression header

lib/utils.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict'
22

3+
const { Readable: NodeReadable } = require('node:stream')
4+
35
// https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
46
function isZstd (buffer) {
57
return (
@@ -49,6 +51,18 @@ function isStream (stream) {
4951
return stream !== null && typeof stream === 'object' && typeof stream.pipe === 'function'
5052
}
5153

54+
function isWebReadableStream (obj) {
55+
return obj instanceof ReadableStream
56+
}
57+
58+
function isFetchResponse (obj) {
59+
return obj instanceof Response
60+
}
61+
62+
function webStreamToNodeReadable (webStream) {
63+
return NodeReadable.fromWeb(webStream)
64+
}
65+
5266
/**
5367
* Provide a async iteratable for Readable.from
5468
*/
@@ -90,4 +104,4 @@ async function * intoAsyncIterator (payload) {
90104
yield payload
91105
}
92106

93-
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator }
107+
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable }

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"url": "git+https://github.com/fastify/fastify-compress.git"
8484
},
8585
"tsd": {
86-
"directory": "test/types"
86+
"directory": "types"
8787
},
8888
"publishConfig": {
8989
"access": "public"

test/global-compress.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const JSONStream = require('jsonstream')
99
const Fastify = require('fastify')
1010
const compressPlugin = require('../index')
1111
const { once } = require('node:events')
12+
const { ReadableStream: WebReadableStream, Response } = globalThis
1213

1314
describe('When `global` is not set, it is `true` by default :', async () => {
1415
test('it should compress Buffer data using brotli when `Accept-Encoding` request header is `br`', async (t) => {
@@ -265,6 +266,54 @@ describe('When `global` is not set, it is `true` by default :', async () => {
265266
const payload = zlib.gunzipSync(response.rawPayload)
266267
t.assert.equal(payload.toString('utf-8'), 'hello')
267268
})
269+
270+
test('it should compress a Fetch API Response body', async (t) => {
271+
t.plan(1)
272+
273+
const fastify = Fastify()
274+
await fastify.register(compressPlugin, { threshold: 0 })
275+
276+
const body = 'hello from fetch response'
277+
fastify.get('/fetch-resp', (_request, reply) => {
278+
const resp = new Response(body, { headers: { 'content-type': 'text/plain' } })
279+
reply.send(resp)
280+
})
281+
282+
const response = await fastify.inject({
283+
url: '/fetch-resp',
284+
method: 'GET',
285+
headers: { 'accept-encoding': 'gzip' }
286+
})
287+
const payload = zlib.gunzipSync(response.rawPayload)
288+
t.assert.equal(payload.toString('utf-8'), body)
289+
})
290+
291+
test('it should compress a Web ReadableStream body', async (t) => {
292+
t.plan(1)
293+
294+
const fastify = Fastify()
295+
await fastify.register(compressPlugin, { threshold: 0 })
296+
297+
const body = 'hello from web stream'
298+
fastify.get('/web-stream', (_request, reply) => {
299+
const stream = new WebReadableStream({
300+
start (controller) {
301+
controller.enqueue(Buffer.from(body))
302+
controller.close()
303+
}
304+
})
305+
reply.header('content-type', 'text/plain')
306+
reply.send(stream)
307+
})
308+
309+
const response = await fastify.inject({
310+
url: '/web-stream',
311+
method: 'GET',
312+
headers: { 'accept-encoding': 'gzip' }
313+
})
314+
const payload = zlib.gunzipSync(response.rawPayload)
315+
t.assert.equal(payload.toString('utf-8'), body)
316+
})
268317
})
269318

270319
describe('It should send compressed Stream data when `global` is `true` :', async () => {

test/routes-compress.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,33 @@ test('It should avoid to trigger `onSend` hook twice', async (t) => {
440440
})
441441
t.assert.deepEqual(JSON.parse(zlib.brotliDecompressSync(response.rawPayload)), { hi: true })
442442
})
443+
444+
test('reply.compress should handle Fetch Response', async (t) => {
445+
t.plan(1)
446+
const fastify = Fastify()
447+
await fastify.register(compressPlugin, { global: true, threshold: 0 })
448+
fastify.get('/', (_req, reply) => {
449+
const r = new Response('from reply.compress', { headers: { 'content-type': 'text/plain' } })
450+
reply.compress(r)
451+
})
452+
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
453+
t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress')
454+
})
455+
456+
test('reply.compress should handle Web ReadableStream', async (t) => {
457+
t.plan(1)
458+
const fastify = Fastify()
459+
await fastify.register(compressPlugin, { global: true, threshold: 0 })
460+
fastify.get('/', (_req, reply) => {
461+
const stream = new ReadableStream({
462+
start (controller) {
463+
controller.enqueue(Buffer.from('from webstream'))
464+
controller.close()
465+
}
466+
})
467+
reply.header('content-type', 'text/plain')
468+
reply.compress(stream)
469+
})
470+
const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } })
471+
t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from webstream')
472+
})

types/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ declare module 'fastify' {
2626
}
2727

2828
interface FastifyReply {
29-
compress(input: Stream | Input): void;
29+
compress(input: Stream | Input | Response | ReadableStream): void;
3030
}
3131

3232
export interface RouteOptions {

types/index.test-d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,18 @@ expectError(appThatTriggerAnError.register(fastifyCompress, {
132132
global: true,
133133
thisOptionDoesNotExist: 'trigger a typescript error'
134134
}))
135+
136+
app.get('/ts-fetch-response', async (_request, reply) => {
137+
const resp = new Response('ok', { headers: { 'content-type': 'text/plain' } })
138+
expectType<void>(reply.compress(resp))
139+
})
140+
141+
app.get('/ts-web-readable-stream', async (_request, reply) => {
142+
const stream = new ReadableStream({
143+
start (controller) {
144+
controller.enqueue(new Uint8Array([1, 2, 3]))
145+
controller.close()
146+
}
147+
})
148+
expectType<void>(reply.compress(stream))
149+
})

0 commit comments

Comments
 (0)