From 610aba30250a3faab48715f686f50575b89dd985 Mon Sep 17 00:00:00 2001 From: Gil Pedersen Date: Tue, 12 Aug 2025 21:11:50 +0200 Subject: [PATCH 1/2] Support handlers calling res.destroy() to abort sending a response --- API.md | 3 +- lib/index.d.ts | 7 +++- lib/response.js | 27 +++++++++++++-- test/index.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 5 deletions(-) diff --git a/API.md b/API.md index 121d0c8..1fe7e1b 100755 --- a/API.md +++ b/API.md @@ -65,11 +65,12 @@ Returns a response object where: - `req` - the simulated request object. - `res` - the simulated response object. - `headers` - an object containing the response headers. -- `statusCode` - the HTTP status code. +- `statusCode` - the HTTP status code. If response is aborted before headers are sent, the code is `499`. - `statusMessage` - the HTTP status message. - `payload` - the payload as a UTF-8 encoded string. - `rawPayload` - the raw payload as a Buffer. - `trailers` - an object containing the response trailers. +- `aborted` - optional property which is `true` for aborted, ie. not fully transmitted, responses. ### `Shot.isInjection(obj)` diff --git a/lib/index.d.ts b/lib/index.d.ts index 23aef90..235c471 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -47,7 +47,7 @@ export interface ResponseObject { headers: OutgoingHttpHeaders; /** - * The HTTP status code. + * The HTTP status code. If response is aborted before headers are sent, the code is `499`. */ statusCode: number; @@ -70,6 +70,11 @@ export interface ResponseObject { * An object containing the response trailers */ trailers: NodeJS.Dict; + + /** + * A boolean which is `true` for aborted, ie. not fully transmitted, responses. + */ + aborted?: true; } type PartialURL = Pick & { pathname: string }; diff --git a/lib/response.js b/lib/response.js index 681b978..8fb80b3 100755 --- a/lib/response.js +++ b/lib/response.js @@ -3,6 +3,8 @@ const Http = require('http'); const Stream = require('stream'); +const Hoek = require('@hapi/hoek'); + const Symbols = require('./symbols'); @@ -17,21 +19,40 @@ exports = module.exports = internals.Response = class extends Http.ServerRespons this._shot = { headers: null, trailers: {}, payloadChunks: [] }; this.assignSocket(internals.nullSocket()); + this.socket.on('error', Hoek.ignore); // The socket can be destroyed with an error + if (req._shot.simulate.close) { // Ensure premature, manual close is forwarded to res. // In HttpServer the socket closing actually triggers close on both req and res. req.once('close', () => { - process.nextTick(() => this.emit('close')); + process.nextTick(() => this.destroy()); }); } - this.once('finish', () => { + const finalize = (aborted) => { const res = internals.payload(this); res.raw.req = req; + if (aborted) { + res.aborted = aborted; + if (!this.headersSent) { + res.statusCode = 499; + } + } + + this.removeListener('close', abort); + process.nextTick(() => onEnd(res)); - }); + }; + + const abort = () => finalize(true); + + this.once('finish', finalize); + + // Add fallback listener that will not be called if 'finish' is emitted first + + this.on('close', abort); } writeHead(...args) { diff --git a/test/index.js b/test/index.js index e4083f0..4b2a628 100755 --- a/test/index.js +++ b/test/index.js @@ -575,6 +575,96 @@ describe('inject()', () => { const err = await expect(Shot.inject((req, res) => { }, { url: '/', simulate: { end: 'wrong input' } })).to.reject(); expect(err.isJoi).to.be.true(); }); + + it('returns aborted on immediate res.destroy()', async () => { + + const dispatch = function (req, res) { + + res.destroy(); + }; + + const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); + expect(res.aborted).to.be.true(); + expect(res.statusCode).to.equal(499); + expect(res.raw.res.errored).to.not.exist(); + }); + + it('returns aborted on immediate res.destroy(error)', async () => { + + const dispatch = function (req, res) { + + res.destroy(new Error('stop')); + }; + + const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); + expect(res.aborted).to.be.true(); + expect(res.statusCode).to.equal(499); + expect(res.raw.res.errored).to.be.an.error('stop'); + }); + + it('returns aborted on res.destroy() while transmitting payload', async () => { + + const dispatch = function (req, res) { + + res.writeHead(404); + res.write('data'); + setTimeout(() => res.destroy(), 1); + }; + + const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); + expect(res.aborted).to.be.true(); + expect(res.statusCode).to.equal(404); + expect(res.raw.res.errored).to.not.exist(); + expect(res.payload).to.equal('data'); + }); + + it('returns aborted on res.destroy(error) while transmitting payload', async () => { + + const dispatch = function (req, res) { + + res.writeHead(404); + res.write('data'); + setTimeout(() => res.destroy(new Error('stop')), 1); + }; + + const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); + expect(res.aborted).to.be.true(); + expect(res.statusCode).to.equal(404); + expect(res.raw.res.errored).to.be.an.error('stop'); + expect(res.payload).to.equal('data'); + }); + + it('handles res.destroy() after transmitting payload', async () => { + + const dispatch = function (req, res) { + + res.writeHead(404); + res.end('data'); + res.destroy(); + }; + + const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); + expect(res.aborted).to.not.exist(); + expect(res.statusCode).to.equal(404); + expect(res.raw.res.errored).to.not.exist(); + expect(res.payload).to.equal('data'); + }); + + it('handles res.destroy(error) after transmitting payload', async () => { + + const dispatch = function (req, res) { + + res.writeHead(404); + res.end('data'); + res.destroy(new Error('stop')); + }; + + const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); + expect(res.aborted).to.not.exist(); + expect(res.statusCode).to.equal(404); + expect(res.raw.res.errored).to.be.an.error('stop'); + expect(res.payload).to.equal('data'); + }); }); describe('writeHead()', () => { From dd468e0e3efee60af5ce30337bf5ab9d966304fe Mon Sep 17 00:00:00 2001 From: Gil Pedersen Date: Tue, 12 Aug 2025 23:32:18 +0200 Subject: [PATCH 2/2] Don't test for res.errored which only works on node 18+ --- test/index.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/index.js b/test/index.js index 4b2a628..b69cec7 100755 --- a/test/index.js +++ b/test/index.js @@ -586,7 +586,6 @@ describe('inject()', () => { const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); expect(res.aborted).to.be.true(); expect(res.statusCode).to.equal(499); - expect(res.raw.res.errored).to.not.exist(); }); it('returns aborted on immediate res.destroy(error)', async () => { @@ -599,7 +598,6 @@ describe('inject()', () => { const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); expect(res.aborted).to.be.true(); expect(res.statusCode).to.equal(499); - expect(res.raw.res.errored).to.be.an.error('stop'); }); it('returns aborted on res.destroy() while transmitting payload', async () => { @@ -614,7 +612,6 @@ describe('inject()', () => { const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); expect(res.aborted).to.be.true(); expect(res.statusCode).to.equal(404); - expect(res.raw.res.errored).to.not.exist(); expect(res.payload).to.equal('data'); }); @@ -630,7 +627,6 @@ describe('inject()', () => { const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); expect(res.aborted).to.be.true(); expect(res.statusCode).to.equal(404); - expect(res.raw.res.errored).to.be.an.error('stop'); expect(res.payload).to.equal('data'); }); @@ -646,7 +642,6 @@ describe('inject()', () => { const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); expect(res.aborted).to.not.exist(); expect(res.statusCode).to.equal(404); - expect(res.raw.res.errored).to.not.exist(); expect(res.payload).to.equal('data'); }); @@ -662,7 +657,6 @@ describe('inject()', () => { const res = await Shot.inject(dispatch, { method: 'get', url: '/' }); expect(res.aborted).to.not.exist(); expect(res.statusCode).to.equal(404); - expect(res.raw.res.errored).to.be.an.error('stop'); expect(res.payload).to.equal('data'); }); });