diff --git a/contract-tests/index.js b/contract-tests/index.js index 06430d0a58..e214db2856 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -31,6 +31,7 @@ app.get('/', (req, res) => { 'migrations', 'event-sampling', 'strongly-typed', + 'polling-gzip' ], }); }); diff --git a/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts b/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts index fda2c3c669..ab4b7168f8 100644 --- a/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts +++ b/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts @@ -1,4 +1,5 @@ import * as http from 'http'; +import * as zlib from 'zlib'; import NodeRequests from '../../src/platform/NodeRequests'; @@ -57,6 +58,9 @@ describe('given a default instance of NodeRequests', () => { res.destroy(); resetResolve(); }, 0); + } else if ((req.url?.indexOf('gzip') || -1) >= 0) { + res.setHeader('Content-Encoding', 'gzip'); + res.end(zlib.gzipSync(Buffer.from(JSON_RESPONSE, 'utf8'))); } else { res.end(TEXT_RESPONSE); } @@ -144,4 +148,21 @@ describe('given a default instance of NodeRequests', () => { await res.json(); }).rejects.toThrow(); }); + + it('includes accept-encoding header', async () => { + await requests.fetch(`http://localhost:${PORT}/gzip`, { method: 'GET' }); + const serverResult = await promise; + expect(serverResult.method).toEqual('GET'); + expect(serverResult.headers['accept-encoding']).toEqual('gzip'); + }); + + it('can get compressed json from a response', async () => { + const res = await requests.fetch(`http://localhost:${PORT}/gzip`, { method: 'GET' }); + expect(res.headers.get('content-type')).toEqual('text/plain'); + const json = await res.json(); + expect(json).toEqual({ text: 'value' }); + const serverResult = await promise; + expect(serverResult.method).toEqual('GET'); + expect(serverResult.body).toEqual(''); + }); }); diff --git a/packages/sdk/server-node/src/platform/NodeRequests.ts b/packages/sdk/server-node/src/platform/NodeRequests.ts index 24f79b0cbf..3dee54c1d2 100644 --- a/packages/sdk/server-node/src/platform/NodeRequests.ts +++ b/packages/sdk/server-node/src/platform/NodeRequests.ts @@ -110,12 +110,22 @@ export default class NodeRequests implements platform.Requests { const isSecure = url.startsWith('https://'); const impl = isSecure ? https : http; + // For get requests we are going to automatically support compressed responses. + // Note this does not affect SSE as the event source is not using this fetch implementation. + const headers = + options.method?.toLowerCase() === 'get' + ? { + ...options.headers, + 'accept-encoding': 'gzip', + } + : options.headers; + return new Promise((resolve, reject) => { const req = impl.request( url, { timeout: options.timeout, - headers: options.headers, + headers, method: options.method, agent: this.agent, }, diff --git a/packages/sdk/server-node/src/platform/NodeResponse.ts b/packages/sdk/server-node/src/platform/NodeResponse.ts index 70c8861c0b..2f765b23f3 100644 --- a/packages/sdk/server-node/src/platform/NodeResponse.ts +++ b/packages/sdk/server-node/src/platform/NodeResponse.ts @@ -1,4 +1,6 @@ import * as http from 'http'; +import { pipeline, Writable } from 'stream'; +import * as zlib from 'zlib'; import { platform } from '@launchdarkly/js-server-sdk-common'; @@ -7,7 +9,15 @@ import HeaderWrapper from './HeaderWrapper'; export default class NodeResponse implements platform.Response { incomingMessage: http.IncomingMessage; - body: any[] = []; + chunks: any[] = []; + + memoryStream: Writable = new Writable({ + decodeStrings: true, + write: (chunk, _enc, next) => { + this.chunks.push(chunk); + next(); + }, + }); promise: Promise; @@ -26,20 +36,24 @@ export default class NodeResponse implements platform.Response { this.incomingMessage = res; this.promise = new Promise((resolve, reject) => { - res.on('data', (chunk) => { - this.body.push(chunk); - }); - - res.on('error', (err) => { - this.rejection = err; - if (this.listened) { - reject(err); + // Called on error or completion of the pipeline. + const pipelineCallback = (err: any) => { + if (err) { + this.rejection = err; + if (this.listened) { + reject(err); + } } - }); - - res.on('end', () => { - resolve(Buffer.concat(this.body).toString()); - }); + return resolve(Buffer.concat(this.chunks).toString()); + }; + switch (res.headers['content-encoding']) { + case 'gzip': + pipeline(res, zlib.createGunzip(), this.memoryStream, pipelineCallback); + break; + default: + pipeline(res, this.memoryStream, pipelineCallback); + break; + } }); }