Skip to content

Commit ea64a72

Browse files
authored
feat: add support for Fetch API Response and Web ReadableStream (#374)
1 parent e706142 commit ea64a72

File tree

8 files changed

+178
-12
lines changed

8 files changed

+178
-12
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: 20 additions & 5 deletions
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,7 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
254254
if (payload == null) {
255255
return next()
256256
}
257+
257258
const responseEncoding = reply.getHeader('Content-Encoding')
258259
if (responseEncoding && responseEncoding !== 'identity') {
259260
// response is already compressed
@@ -290,10 +291,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) {
290291
}
291292

292293
if (typeof payload.pipe !== 'function') {
293-
if (Buffer.byteLength(payload) < params.threshold) {
294-
return next()
294+
if (isFetchResponse(payload)) {
295+
payload = payload.body
296+
}
297+
298+
if (isWebReadableStream(payload)) {
299+
payload = webStreamToNodeReadable(payload)
300+
} else {
301+
if (Buffer.byteLength(payload) < params.threshold) {
302+
return next()
303+
}
304+
payload = Readable.from(intoAsyncIterator(payload))
295305
}
296-
payload = Readable.from(intoAsyncIterator(payload))
297306
}
298307

299308
setVaryHeader(reply)
@@ -408,7 +417,13 @@ function compress (params) {
408417
}
409418

410419
if (typeof payload.pipe !== 'function') {
411-
if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
420+
if (isFetchResponse(payload)) {
421+
payload = payload.body
422+
}
423+
424+
if (isWebReadableStream(payload)) {
425+
payload = webStreamToNodeReadable(payload)
426+
} else if (!Buffer.isBuffer(payload) && typeof payload !== 'string') {
412427
payload = this.serialize(payload)
413428
}
414429
}

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)