diff --git a/integration/send-files/e2e/express.spec.ts b/integration/send-files/e2e/express.spec.ts index f95e923389b..cf78184c045 100644 --- a/integration/send-files/e2e/express.spec.ts +++ b/integration/send-files/e2e/express.spec.ts @@ -8,6 +8,11 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; +import { + getHttpBaseOptions, + sendCanceledHttpRequest, + sendHttpRequest, +} from './utils'; const readme = readFileSync(join(process.cwd(), 'Readme.md')); const readmeString = readme.toString(); @@ -68,4 +73,11 @@ describe('Express FileSend', () => { it('should return an error if the file does not exist', async () => { return request(app.getHttpServer()).get('/file/not/exist').expect(400); }); + it('should allow for the client to end the response and be able to make another', async () => { + await app.listen(0); + const url = await getHttpBaseOptions(app); + await sendCanceledHttpRequest(new URL('/file/slow', url)); + const res = await sendHttpRequest(new URL('/file/stream', url)); + expect(res.statusCode).to.be.eq(200); + }).timeout(5000); }); diff --git a/integration/send-files/e2e/utils.ts b/integration/send-files/e2e/utils.ts new file mode 100644 index 00000000000..88d6759e452 --- /dev/null +++ b/integration/send-files/e2e/utils.ts @@ -0,0 +1,47 @@ +import { INestApplication } from '@nestjs/common'; +import { IncomingMessage, request, RequestOptions } from 'http'; +import { URL } from 'url'; + +export const getHttpBaseOptions = async ( + app: INestApplication, +): Promise => { + const url = await app.getUrl(); + return new URL(url); +}; + +export const sendCanceledHttpRequest = async (url: URL) => { + return new Promise((resolve, reject) => { + const req = request(url, res => { + // close the request once we get the first response of data + res.on('data', () => { + req.destroy(); + }); + // response is closed, move on to next request and verify it's doable + res.on('close', resolve); + }); + // fire the request + req.end(); + }); +}; + +export const sendHttpRequest = async (url: URL) => { + return new Promise((resolve, reject) => { + const req = request(url, res => { + // this makes sure that the response actually starts and is read. We could verify this value against the same + // that is in an earlier test, but all we care about in _this_ test is that the status code is 200 + res.on('data', chunk => { + // no op + }); + // fail the test if somethin goes wrong + res.on('error', err => { + reject(err); + }); + // pass the response back so we can verify values in the test + res.on('end', () => { + resolve(res); + }); + }); + // fire the request + req.end(); + }); +}; diff --git a/integration/send-files/src/app.controller.ts b/integration/send-files/src/app.controller.ts index 9715784bdbf..69fa20ec069 100644 --- a/integration/send-files/src/app.controller.ts +++ b/integration/send-files/src/app.controller.ts @@ -36,4 +36,9 @@ export class AppController { getNonExistantFile(): StreamableFile { return this.appService.getFileThatDoesNotExist(); } + + @Get('/file/slow') + getSlowFile(): StreamableFile { + return this.appService.getSlowStream(); + } } diff --git a/integration/send-files/src/app.service.ts b/integration/send-files/src/app.service.ts index af94bf5b7f1..e5f920c48b8 100644 --- a/integration/send-files/src/app.service.ts +++ b/integration/send-files/src/app.service.ts @@ -1,11 +1,16 @@ import { Injectable, StreamableFile } from '@nestjs/common'; +import { randomBytes } from 'crypto'; import { createReadStream, readFileSync } from 'fs'; import { join } from 'path'; import { Observable, of } from 'rxjs'; +import { Readable } from 'stream'; import { NonFile } from './non-file'; @Injectable() export class AppService { + // `randomBytes` has a max value of 2^31 -1. That's all this is + private readonly MAX_BITES = Math.pow(2, 31) - 1; + getReadStream(): StreamableFile { return new StreamableFile( createReadStream(join(process.cwd(), 'Readme.md')), @@ -39,4 +44,12 @@ export class AppService { getFileThatDoesNotExist(): StreamableFile { return new StreamableFile(createReadStream('does-not-exist.txt')); } + + getSlowStream(): StreamableFile { + const stream = new Readable(); + stream.push(Buffer.from(randomBytes(this.MAX_BITES))); + // necessary for a `new Readable()`. Doesn't do anything + stream._read = () => {}; + return new StreamableFile(stream); + } } diff --git a/packages/common/file-stream/streamable-file.ts b/packages/common/file-stream/streamable-file.ts index f57b857ee69..dae63beda01 100644 --- a/packages/common/file-stream/streamable-file.ts +++ b/packages/common/file-stream/streamable-file.ts @@ -4,6 +4,7 @@ import { isFunction } from '../utils/shared.utils'; import { StreamableFileOptions } from './streamable-options.interface'; export interface StreamableHandlerResponse { + destroyed: boolean; statusCode: number; send: (msg: string) => void; } @@ -15,8 +16,10 @@ export class StreamableFile { err: Error, response: StreamableHandlerResponse, ) => void = (err: Error, res) => { - res.statusCode = 400; - res.send(err.message); + if (!res.destroyed) { + res.statusCode = 400; + res.send(err.message); + } }; constructor(buffer: Uint8Array, options?: StreamableFileOptions);