diff --git a/README.md b/README.md index 9a6fb26..0490325 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,40 @@ app.get('/', (req, reply) => { await app.listen({ port: 3000 }) ``` +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. + +```js +import fastify from 'fastify' + +const app = fastify() +await app.register(import('@fastify/compress'), { global: true }) + +app.get('/', async (req, reply) => { + const resp = new Response('Hello from Fetch Response') + reply.compress(resp) +}) +``` + +```js +app.get('/', async (req, reply) => { + return new Response('Hello from Fetch Response') +}) +``` + +```js +app.get('/', (req, reply) => { + const stream = new ReadableStream({ + start (controller) { + controller.enqueue(new TextEncoder().encode('Hello from Web ReadableStream')) + controller.close() + } + }) + + reply.header('content-type', 'text/plain') + reply.compress(stream) +}) +``` + ## Compress Options ### threshold @@ -222,15 +256,24 @@ This plugin adds a `preParsing` hook to decompress the request payload based on Currently, the following encoding tokens are supported: -1. `zstd` (Node.js 22.15+/23.8+) -2. `br` -3. `gzip` -4. `deflate` +- `zstd` (Node.js 22.15+/23.8+) +- `br` +- `gzip` +- `deflate` If an unsupported encoding or invalid payload is received, the plugin throws an error. If the request header is missing, the plugin yields to the next hook. +### Supported payload types + +The plugin supports compressing the following payload types: + +- Strings and Buffers +- Node.js streams +- Response objects (from the Fetch API) +- ReadableStream objects (from the Web Streams API) + ### Global hook The global request decompression hook is enabled by default. To disable it, pass `{ global: false }`: diff --git a/index.js b/index.js index 6e5148f..6a90522 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,7 @@ const { Minipass } = require('minipass') const pumpify = require('pumpify') const { Readable } = require('readable-stream') -const { isStream, isGzip, isDeflate, intoAsyncIterator } = require('./lib/utils') +const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } = require('./lib/utils') const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415) 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) { if (payload == null) { return next() } + const responseEncoding = reply.getHeader('Content-Encoding') if (responseEncoding && responseEncoding !== 'identity') { // response is already compressed @@ -290,10 +291,18 @@ function buildRouteCompress (_fastify, params, routeOptions, decorateOnly) { } if (typeof payload.pipe !== 'function') { - if (Buffer.byteLength(payload) < params.threshold) { - return next() + if (isFetchResponse(payload)) { + payload = payload.body + } + + if (isWebReadableStream(payload)) { + payload = webStreamToNodeReadable(payload) + } else { + if (Buffer.byteLength(payload) < params.threshold) { + return next() + } + payload = Readable.from(intoAsyncIterator(payload)) } - payload = Readable.from(intoAsyncIterator(payload)) } setVaryHeader(reply) @@ -408,7 +417,13 @@ function compress (params) { } if (typeof payload.pipe !== 'function') { - if (!Buffer.isBuffer(payload) && typeof payload !== 'string') { + if (isFetchResponse(payload)) { + payload = payload.body + } + + if (isWebReadableStream(payload)) { + payload = webStreamToNodeReadable(payload) + } else if (!Buffer.isBuffer(payload) && typeof payload !== 'string') { payload = this.serialize(payload) } } diff --git a/lib/utils.js b/lib/utils.js index 02eb02c..70854f4 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,5 +1,7 @@ 'use strict' +const { Readable: NodeReadable } = require('node:stream') + // https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1 function isZstd (buffer) { return ( @@ -49,6 +51,18 @@ function isStream (stream) { return stream !== null && typeof stream === 'object' && typeof stream.pipe === 'function' } +function isWebReadableStream (obj) { + return obj instanceof ReadableStream +} + +function isFetchResponse (obj) { + return obj instanceof Response +} + +function webStreamToNodeReadable (webStream) { + return NodeReadable.fromWeb(webStream) +} + /** * Provide a async iteratable for Readable.from */ @@ -90,4 +104,4 @@ async function * intoAsyncIterator (payload) { yield payload } -module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator } +module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } diff --git a/package.json b/package.json index 17caefe..9049a0b 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "url": "git+https://github.com/fastify/fastify-compress.git" }, "tsd": { - "directory": "test/types" + "directory": "types" }, "publishConfig": { "access": "public" diff --git a/test/global-compress.test.js b/test/global-compress.test.js index b986b8f..85e2906 100644 --- a/test/global-compress.test.js +++ b/test/global-compress.test.js @@ -9,6 +9,7 @@ const JSONStream = require('jsonstream') const Fastify = require('fastify') const compressPlugin = require('../index') const { once } = require('node:events') +const { ReadableStream: WebReadableStream, Response } = globalThis describe('When `global` is not set, it is `true` by default :', async () => { 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 () => { const payload = zlib.gunzipSync(response.rawPayload) t.assert.equal(payload.toString('utf-8'), 'hello') }) + + test('it should compress a Fetch API Response body', async (t) => { + t.plan(1) + + const fastify = Fastify() + await fastify.register(compressPlugin, { threshold: 0 }) + + const body = 'hello from fetch response' + fastify.get('/fetch-resp', (_request, reply) => { + const resp = new Response(body, { headers: { 'content-type': 'text/plain' } }) + reply.send(resp) + }) + + const response = await fastify.inject({ + url: '/fetch-resp', + method: 'GET', + headers: { 'accept-encoding': 'gzip' } + }) + const payload = zlib.gunzipSync(response.rawPayload) + t.assert.equal(payload.toString('utf-8'), body) + }) + + test('it should compress a Web ReadableStream body', async (t) => { + t.plan(1) + + const fastify = Fastify() + await fastify.register(compressPlugin, { threshold: 0 }) + + const body = 'hello from web stream' + fastify.get('/web-stream', (_request, reply) => { + const stream = new WebReadableStream({ + start (controller) { + controller.enqueue(Buffer.from(body)) + controller.close() + } + }) + reply.header('content-type', 'text/plain') + reply.send(stream) + }) + + const response = await fastify.inject({ + url: '/web-stream', + method: 'GET', + headers: { 'accept-encoding': 'gzip' } + }) + const payload = zlib.gunzipSync(response.rawPayload) + t.assert.equal(payload.toString('utf-8'), body) + }) }) describe('It should send compressed Stream data when `global` is `true` :', async () => { diff --git a/test/routes-compress.test.js b/test/routes-compress.test.js index 273f5f1..fc5e5bf 100644 --- a/test/routes-compress.test.js +++ b/test/routes-compress.test.js @@ -440,3 +440,33 @@ test('It should avoid to trigger `onSend` hook twice', async (t) => { }) t.assert.deepEqual(JSON.parse(zlib.brotliDecompressSync(response.rawPayload)), { hi: true }) }) + +test('reply.compress should handle Fetch Response', async (t) => { + t.plan(1) + const fastify = Fastify() + await fastify.register(compressPlugin, { global: true, threshold: 0 }) + fastify.get('/', (_req, reply) => { + const r = new Response('from reply.compress', { headers: { 'content-type': 'text/plain' } }) + reply.compress(r) + }) + const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } }) + t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from reply.compress') +}) + +test('reply.compress should handle Web ReadableStream', async (t) => { + t.plan(1) + const fastify = Fastify() + await fastify.register(compressPlugin, { global: true, threshold: 0 }) + fastify.get('/', (_req, reply) => { + const stream = new ReadableStream({ + start (controller) { + controller.enqueue(Buffer.from('from webstream')) + controller.close() + } + }) + reply.header('content-type', 'text/plain') + reply.compress(stream) + }) + const res = await fastify.inject({ url: '/', method: 'GET', headers: { 'accept-encoding': 'gzip' } }) + t.assert.equal(zlib.gunzipSync(res.rawPayload).toString('utf8'), 'from webstream') +}) diff --git a/types/index.d.ts b/types/index.d.ts index 71f7a44..5c26c9f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -26,7 +26,7 @@ declare module 'fastify' { } interface FastifyReply { - compress(input: Stream | Input): void; + compress(input: Stream | Input | Response | ReadableStream): void; } export interface RouteOptions { diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 32a32b9..e11ec44 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -132,3 +132,18 @@ expectError(appThatTriggerAnError.register(fastifyCompress, { global: true, thisOptionDoesNotExist: 'trigger a typescript error' })) + +app.get('/ts-fetch-response', async (_request, reply) => { + const resp = new Response('ok', { headers: { 'content-type': 'text/plain' } }) + expectType(reply.compress(resp)) +}) + +app.get('/ts-web-readable-stream', async (_request, reply) => { + const stream = new ReadableStream({ + start (controller) { + controller.enqueue(new Uint8Array([1, 2, 3])) + controller.close() + } + }) + expectType(reply.compress(stream)) +})