Skip to content

Commit 73991ef

Browse files
committed
Support handlers calling res.destroy() to abort sending a response
1 parent 2dca411 commit 73991ef

File tree

4 files changed

+121
-5
lines changed

4 files changed

+121
-5
lines changed

API.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ Returns a response object where:
6565
- `req` - the simulated request object.
6666
- `res` - the simulated response object.
6767
- `headers` - an object containing the response headers.
68-
- `statusCode` - the HTTP status code.
68+
- `statusCode` - the HTTP status code. If response is aborted before headers are sent, the code is `499`.
6969
- `statusMessage` - the HTTP status message.
7070
- `payload` - the payload as a UTF-8 encoded string.
7171
- `rawPayload` - the raw payload as a Buffer.
7272
- `trailers` - an object containing the response trailers.
73+
- `aborted` - optional property which is `true` for aborted, ie. not fully transmitted, responses.
7374

7475
### `Shot.isInjection(obj)`
7576

lib/index.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export interface ResponseObject {
4747
headers: OutgoingHttpHeaders;
4848

4949
/**
50-
* The HTTP status code.
50+
* The HTTP status code. If response is aborted before headers are sent, the code is `499`.
5151
*/
5252
statusCode: number;
5353

@@ -70,6 +70,11 @@ export interface ResponseObject {
7070
* An object containing the response trailers
7171
*/
7272
trailers: NodeJS.Dict<string>;
73+
74+
/**
75+
* A boolean which is `true` for aborted, ie. not fully transmitted, responses.
76+
*/
77+
aborted?: true;
7378
}
7479

7580
type PartialURL = Pick<UrlObject, 'protocol' | 'hostname' | 'port' | 'query'> & { pathname: string };

lib/response.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
const Http = require('http');
44
const Stream = require('stream');
55

6+
const Hoek = require('@hapi/hoek');
7+
68
const Symbols = require('./symbols');
79

810

@@ -17,21 +19,39 @@ exports = module.exports = internals.Response = class extends Http.ServerRespons
1719
this._shot = { headers: null, trailers: {}, payloadChunks: [] };
1820
this.assignSocket(internals.nullSocket());
1921

22+
this.socket.on('error', Hoek.ignore); // The socket can be destroyed with an error
23+
2024
if (req._shot.simulate.close) {
2125
// Ensure premature, manual close is forwarded to res.
2226
// In HttpServer the socket closing actually triggers close on both req and res.
2327
req.once('close', () => {
2428

25-
process.nextTick(() => this.emit('close'));
29+
process.nextTick(() => this.destroy());
2630
});
2731
}
2832

29-
this.once('finish', () => {
33+
const finalize = (aborted) => {
3034

3135
const res = internals.payload(this);
3236
res.raw.req = req;
37+
if (aborted) {
38+
res.aborted = aborted;
39+
if (!this.headersSent) {
40+
res.statusCode = 499;
41+
}
42+
}
43+
44+
this.removeListener('close', abort);
45+
3346
process.nextTick(() => onEnd(res));
34-
});
47+
};
48+
const abort = finalize.bind(this, true);
49+
50+
this.once('finish', finalize);
51+
52+
// Add fallback listener that will not be called if 'finish' is emitted first
53+
54+
this.on('close', abort);
3555
}
3656

3757
writeHead(...args) {

test/index.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,96 @@ describe('inject()', () => {
537537
});
538538
expect(res.payload.toString()).to.equal(body.toString());
539539
});
540+
541+
it('returns aborted on immediate res.destroy()', async () => {
542+
543+
const dispatch = function (req, res) {
544+
545+
res.destroy();
546+
};
547+
548+
const res = await Shot.inject(dispatch, { method: 'get', url: '/' });
549+
expect(res.aborted).to.be.true();
550+
expect(res.statusCode).to.equal(499);
551+
expect(res.raw.res.errored).to.not.exist();
552+
});
553+
554+
it('returns aborted on immediate res.destroy(error)', async () => {
555+
556+
const dispatch = function (req, res) {
557+
558+
res.destroy(new Error('stop'));
559+
};
560+
561+
const res = await Shot.inject(dispatch, { method: 'get', url: '/' });
562+
expect(res.aborted).to.be.true();
563+
expect(res.statusCode).to.equal(499);
564+
expect(res.raw.res.errored).to.be.an.error('stop');
565+
});
566+
567+
it('returns aborted on res.destroy() while transmitting payload', async () => {
568+
569+
const dispatch = function (req, res) {
570+
571+
res.writeHead(404);
572+
res.write('data');
573+
setTimeout(() => res.destroy(), 1);
574+
};
575+
576+
const res = await Shot.inject(dispatch, { method: 'get', url: '/' });
577+
expect(res.aborted).to.be.true();
578+
expect(res.statusCode).to.equal(404);
579+
expect(res.raw.res.errored).to.not.exist();
580+
expect(res.payload).to.equal('data');
581+
});
582+
583+
it('returns aborted on res.destroy(error) while transmitting payload', async () => {
584+
585+
const dispatch = function (req, res) {
586+
587+
res.writeHead(404);
588+
res.write('data');
589+
setTimeout(() => res.destroy(new Error('stop')), 1);
590+
};
591+
592+
const res = await Shot.inject(dispatch, { method: 'get', url: '/' });
593+
expect(res.aborted).to.be.true();
594+
expect(res.statusCode).to.equal(404);
595+
expect(res.raw.res.errored).to.be.an.error('stop');
596+
expect(res.payload).to.equal('data');
597+
});
598+
599+
it('handles res.destroy() after transmitting payload', async () => {
600+
601+
const dispatch = function (req, res) {
602+
603+
res.writeHead(404);
604+
res.end('data');
605+
res.destroy();
606+
};
607+
608+
const res = await Shot.inject(dispatch, { method: 'get', url: '/' });
609+
expect(res.aborted).to.not.exist();
610+
expect(res.statusCode).to.equal(404);
611+
expect(res.raw.res.errored).to.not.exist();
612+
expect(res.payload).to.equal('data');
613+
});
614+
615+
it('handles res.destroy(error) after transmitting payload', async () => {
616+
617+
const dispatch = function (req, res) {
618+
619+
res.writeHead(404);
620+
res.end('data');
621+
res.destroy(new Error('stop'));
622+
};
623+
624+
const res = await Shot.inject(dispatch, { method: 'get', url: '/' });
625+
expect(res.aborted).to.not.exist();
626+
expect(res.statusCode).to.equal(404);
627+
expect(res.raw.res.errored).to.be.an.error('stop');
628+
expect(res.payload).to.equal('data');
629+
});
540630
});
541631

542632
describe('writeHead()', () => {

0 commit comments

Comments
 (0)