Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/REST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
recordActionBinary(!hasError, 'connection', 'ws', 'disconnect');
incomingMessages.emit('close');
if (iterator) iterator.return();
request._abort?.();
});
try {
await chainCompletion;
Expand Down
43 changes: 39 additions & 4 deletions server/serverHelpers/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface IncomingMessage extends NodeIncomingMessage {
export class Request {
#body: RequestBody | undefined;
#peerCertificate: any;
#abortController = new AbortController();
public _nodeRequest: IncomingMessage;
public _nodeResponse?: NodeServerResponse;
public method: string;
Expand Down Expand Up @@ -60,14 +61,39 @@ export class Request {
public lastModified?: number;
public lastRefreshed?: number;

constructor(nodeRequest: IncomingMessage, nodeResponse: NodeServerResponse) {
constructor(nodeRequest: IncomingMessage, nodeResponse?: NodeServerResponse) {
this.method = nodeRequest.method;
const url = nodeRequest.url;
this._nodeRequest = nodeRequest;
this._nodeResponse = nodeResponse;
this.url = url;
this.headers = new RequestHeaders(nodeRequest.headers);
this.__harperRequestUpgraded = false;
// Abort the request's signal on premature client disconnect. nodeResponse 'close'
// also fires on clean completion; the writableFinished guard restricts to disconnect.
if (typeof nodeResponse?.on === 'function') {
nodeResponse.on('close', () => {
if (!nodeResponse.writableFinished) this.#abortController.abort();
});
} else if (typeof nodeRequest.socket?.once === 'function') {
// No response on this Request — typically the WebSocket-upgrade path
// (http.ts creates the Request before the ws library takes over). The TCP
// socket close is the fallback abort trigger; REST.ts's ws.on('close') hook
// also calls _abort() and is the primary signal in the WS case. The two
// are redundant by design (idempotent abort) so any future single-arg
// caller still gets disconnect semantics without relying on the WS layer.
nodeRequest.socket.once('close', () => this.#abortController.abort());
}
}
get signal(): AbortSignal {
return this.#abortController.signal;
}
/**
* Abort this request's signal. Used by transports (e.g. WebSocket) that need to
* signal client-side cancellation independently of the Node response lifecycle.
*/
_abort(): void {
this.#abortController.abort();
}
get absoluteURL() {
return this.protocol + '://' + this.host + this.url;
Expand Down Expand Up @@ -119,8 +145,7 @@ export class Request {
return this._nodeRequest.httpVersion;
}
get isAborted() {
// TODO: implement this
return false;
return this.#abortController.signal.aborted;
}
// Expose node request for cases that need direct access (e.g., replication)
get nodeRequest() {
Expand Down Expand Up @@ -391,7 +416,17 @@ export class BunRequest {
return '1.1';
}
get isAborted() {
return false;
return this._webRequest.signal?.aborted ?? false;
}
get signal(): AbortSignal {
// Bun.serve() aborts this signal on HTTP client disconnect. Behavior on
// WebSocket-upgrade is implementation-defined; if the WS path needs
// guaranteed abort-on-close under Bun, wire it through Bun's ws close
// handler with a Bun-side AbortController, similar to REST.ts on Node.
return this._webRequest.signal;
}
_abort(): void {
// On Bun, abort is driven by the underlying Web Request's signal; no-op for parity with Node path.
}
get nodeRequest() {
return null;
Expand Down
59 changes: 58 additions & 1 deletion unitTests/server/serverHelpers/Request.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,68 @@ describe('Request class', function () {
});

it('should return isAborted status', function () {
// Currently always returns false (TODO in implementation)
assert.strictEqual(request.isAborted, false);
});
});

describe('signal (AbortSignal)', function () {
const { EventEmitter } = require('node:events');

function makeNodeRequest() {
return {
method: 'GET',
url: '/test',
headers: {},
socket: Object.assign(new EventEmitter(), {
encrypted: true,
remoteAddress: '127.0.0.1',
getPeerCertificate: sinon.stub().returns({}),
}),
};
}

function makeNodeResponse({ writableFinished = false } = {}) {
const res = new EventEmitter();
res.writableFinished = writableFinished;
return res;
}

it('exposes a live AbortSignal that is not initially aborted', function () {
const request = new Request(makeNodeRequest(), makeNodeResponse());
assert.ok(request.signal instanceof AbortSignal);
assert.strictEqual(request.signal.aborted, false);
assert.strictEqual(request.isAborted, false);
});

it('aborts the signal on nodeResponse close before write is finished', function () {
const nodeResponse = makeNodeResponse({ writableFinished: false });
const request = new Request(makeNodeRequest(), nodeResponse);
nodeResponse.emit('close');
assert.strictEqual(request.signal.aborted, true);
assert.strictEqual(request.isAborted, true);
});

it('does NOT abort the signal on nodeResponse close after write finishes', function () {
const nodeResponse = makeNodeResponse({ writableFinished: true });
const request = new Request(makeNodeRequest(), nodeResponse);
nodeResponse.emit('close');
assert.strictEqual(request.signal.aborted, false);
});

it('aborts on socket close when no nodeResponse is provided (WebSocket-upgrade path)', function () {
const nodeRequest = makeNodeRequest();
const request = new Request(nodeRequest);
nodeRequest.socket.emit('close');
assert.strictEqual(request.signal.aborted, true);
});

it('_abort() aborts the signal explicitly', function () {
const request = new Request(makeNodeRequest(), makeNodeResponse());
request._abort();
assert.strictEqual(request.signal.aborted, true);
});
});

describe('body getter', function () {
it('should create RequestBody instance lazily', function () {
const mockNodeRequest = {
Expand Down
Loading