Skip to content

Commit 6015b28

Browse files
authored
Merge pull request #635 from HarperFast/feat/request-signal
feat: expose request.signal AbortSignal, aborted on client disconnect
2 parents 5d4321e + 166e0db commit 6015b28

3 files changed

Lines changed: 98 additions & 5 deletions

File tree

server/REST.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
340340
recordActionBinary(!hasError, 'connection', 'ws', 'disconnect');
341341
incomingMessages.emit('close');
342342
if (iterator) iterator.return();
343+
request._abort?.();
343344
});
344345
try {
345346
await chainCompletion;

server/serverHelpers/Request.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface IncomingMessage extends NodeIncomingMessage {
2727
export class Request {
2828
#body: RequestBody | undefined;
2929
#peerCertificate: any;
30+
#abortController = new AbortController();
3031
public _nodeRequest: IncomingMessage;
3132
public _nodeResponse?: NodeServerResponse;
3233
public method: string;
@@ -60,14 +61,39 @@ export class Request {
6061
public lastModified?: number;
6162
public lastRefreshed?: number;
6263

63-
constructor(nodeRequest: IncomingMessage, nodeResponse: NodeServerResponse) {
64+
constructor(nodeRequest: IncomingMessage, nodeResponse?: NodeServerResponse) {
6465
this.method = nodeRequest.method;
6566
const url = nodeRequest.url;
6667
this._nodeRequest = nodeRequest;
6768
this._nodeResponse = nodeResponse;
6869
this.url = url;
6970
this.headers = new RequestHeaders(nodeRequest.headers);
7071
this.__harperRequestUpgraded = false;
72+
// Abort the request's signal on premature client disconnect. nodeResponse 'close'
73+
// also fires on clean completion; the writableFinished guard restricts to disconnect.
74+
if (typeof nodeResponse?.on === 'function') {
75+
nodeResponse.on('close', () => {
76+
if (!nodeResponse.writableFinished) this.#abortController.abort();
77+
});
78+
} else if (typeof nodeRequest.socket?.once === 'function') {
79+
// No response on this Request — typically the WebSocket-upgrade path
80+
// (http.ts creates the Request before the ws library takes over). The TCP
81+
// socket close is the fallback abort trigger; REST.ts's ws.on('close') hook
82+
// also calls _abort() and is the primary signal in the WS case. The two
83+
// are redundant by design (idempotent abort) so any future single-arg
84+
// caller still gets disconnect semantics without relying on the WS layer.
85+
nodeRequest.socket.once('close', () => this.#abortController.abort());
86+
}
87+
}
88+
get signal(): AbortSignal {
89+
return this.#abortController.signal;
90+
}
91+
/**
92+
* Abort this request's signal. Used by transports (e.g. WebSocket) that need to
93+
* signal client-side cancellation independently of the Node response lifecycle.
94+
*/
95+
_abort(): void {
96+
this.#abortController.abort();
7197
}
7298
get absoluteURL() {
7399
return this.protocol + '://' + this.host + this.url;
@@ -119,8 +145,7 @@ export class Request {
119145
return this._nodeRequest.httpVersion;
120146
}
121147
get isAborted() {
122-
// TODO: implement this
123-
return false;
148+
return this.#abortController.signal.aborted;
124149
}
125150
// Expose node request for cases that need direct access (e.g., replication)
126151
get nodeRequest() {
@@ -391,7 +416,17 @@ export class BunRequest {
391416
return '1.1';
392417
}
393418
get isAborted() {
394-
return false;
419+
return this._webRequest.signal?.aborted ?? false;
420+
}
421+
get signal(): AbortSignal {
422+
// Bun.serve() aborts this signal on HTTP client disconnect. Behavior on
423+
// WebSocket-upgrade is implementation-defined; if the WS path needs
424+
// guaranteed abort-on-close under Bun, wire it through Bun's ws close
425+
// handler with a Bun-side AbortController, similar to REST.ts on Node.
426+
return this._webRequest.signal;
427+
}
428+
_abort(): void {
429+
// On Bun, abort is driven by the underlying Web Request's signal; no-op for parity with Node path.
395430
}
396431
get nodeRequest() {
397432
return null;

unitTests/server/serverHelpers/Request.test.js

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,68 @@ describe('Request class', function () {
260260
});
261261

262262
it('should return isAborted status', function () {
263-
// Currently always returns false (TODO in implementation)
264263
assert.strictEqual(request.isAborted, false);
265264
});
266265
});
267266

267+
describe('signal (AbortSignal)', function () {
268+
const { EventEmitter } = require('node:events');
269+
270+
function makeNodeRequest() {
271+
return {
272+
method: 'GET',
273+
url: '/test',
274+
headers: {},
275+
socket: Object.assign(new EventEmitter(), {
276+
encrypted: true,
277+
remoteAddress: '127.0.0.1',
278+
getPeerCertificate: sinon.stub().returns({}),
279+
}),
280+
};
281+
}
282+
283+
function makeNodeResponse({ writableFinished = false } = {}) {
284+
const res = new EventEmitter();
285+
res.writableFinished = writableFinished;
286+
return res;
287+
}
288+
289+
it('exposes a live AbortSignal that is not initially aborted', function () {
290+
const request = new Request(makeNodeRequest(), makeNodeResponse());
291+
assert.ok(request.signal instanceof AbortSignal);
292+
assert.strictEqual(request.signal.aborted, false);
293+
assert.strictEqual(request.isAborted, false);
294+
});
295+
296+
it('aborts the signal on nodeResponse close before write is finished', function () {
297+
const nodeResponse = makeNodeResponse({ writableFinished: false });
298+
const request = new Request(makeNodeRequest(), nodeResponse);
299+
nodeResponse.emit('close');
300+
assert.strictEqual(request.signal.aborted, true);
301+
assert.strictEqual(request.isAborted, true);
302+
});
303+
304+
it('does NOT abort the signal on nodeResponse close after write finishes', function () {
305+
const nodeResponse = makeNodeResponse({ writableFinished: true });
306+
const request = new Request(makeNodeRequest(), nodeResponse);
307+
nodeResponse.emit('close');
308+
assert.strictEqual(request.signal.aborted, false);
309+
});
310+
311+
it('aborts on socket close when no nodeResponse is provided (WebSocket-upgrade path)', function () {
312+
const nodeRequest = makeNodeRequest();
313+
const request = new Request(nodeRequest);
314+
nodeRequest.socket.emit('close');
315+
assert.strictEqual(request.signal.aborted, true);
316+
});
317+
318+
it('_abort() aborts the signal explicitly', function () {
319+
const request = new Request(makeNodeRequest(), makeNodeResponse());
320+
request._abort();
321+
assert.strictEqual(request.signal.aborted, true);
322+
});
323+
});
324+
268325
describe('body getter', function () {
269326
it('should create RequestBody instance lazily', function () {
270327
const mockNodeRequest = {

0 commit comments

Comments
 (0)