Skip to content

Commit f50328a

Browse files
RobinTailjakub-msqtgithub-actions[bot]
authored
feat: AbortSignal for SSE (#2966)
Copied from #2965 Related discussion #991 (reply in thread) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Added AbortSignal support to event stream subscriptions; emitters now expose a signal that aborts when the client disconnects to enable proper cancellation and cleanup. - Documentation - Updated changelog for version 25.5.0 to note AbortSignal support. - Added contributor acknowledgements to the README. - Tests - Added tests ensuring the signal is present on emitters and aborts on connection close. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: jakub-msqt <[email protected]> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent cc3d6b6 commit f50328a

File tree

5 files changed

+34
-4
lines changed

5 files changed

+34
-4
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Version 25
44

5+
### v25.5.0
6+
7+
- Feature: `AbortSignal` for subscription handlers:
8+
- The `handler` of `EventStreamFactory::build()` is now equipped with `signal` option;
9+
- That signal is aborted when the client disconnects;
10+
- The feature was suggested and implemented by [@jakub-msqt](https://github.com/jakub-msqt).
11+
512
### v25.4.1
613

714
- This patch fixes the issue for users facing `TypeError: .example is not a function`, but not using that method:

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Therefore, many basic tasks can be accomplished faster and easier, in particular
8585

8686
These people contributed to the improvement of the framework by reporting bugs, making changes and suggesting ideas:
8787

88+
[<img src="https://github.com/jakub-msqt.png" alt="@jakub-msqt" width="50px" />](https://github.com/jakub-msqt)
8889
[<img src="https://github.com/misha-z1nchuk.png" alt="@misha-z1nchuk" width="50px" />](https://github.com/misha-z1nchuk)
8990
[<img src="https://github.com/GreaterTamarack.png" alt="@GreaterTamarack" width="50px" />](https://github.com/GreaterTamarack)
9091
[<img src="https://github.com/pepegc.png" alt="@pepegc" width="50px" />](https://github.com/pepegc)

express-zod-api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "express-zod-api",
3-
"version": "25.4.1",
3+
"version": "25.5.0-beta.1",
44
"description": "A Typescript framework to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.",
55
"license": "MIT",
66
"repository": {

express-zod-api/src/sse.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type EventsMap = Record<string, z.ZodType>;
1616
export interface Emitter<E extends EventsMap> extends FlatObject {
1717
/** @desc Returns true when the connection was closed or terminated */
1818
isClosed: () => boolean;
19+
/** @desc Abort signal bound to the client connection lifecycle */
20+
signal: AbortSignal;
1921
/** @desc Sends an event to the stream according to the declared schema */
2022
emit: <K extends keyof E>(event: K, data: z.input<E[K]>) => void;
2123
}
@@ -55,9 +57,18 @@ export const ensureStream = (response: Response) =>
5557

5658
export const makeMiddleware = <E extends EventsMap>(events: E) =>
5759
new Middleware({
58-
handler: async ({ response }): Promise<Emitter<E>> =>
59-
setTimeout(() => ensureStream(response), headersTimeout) && {
60+
handler: async ({ request, response }): Promise<Emitter<E>> => {
61+
const controller = new AbortController();
62+
63+
request.once("close", () => {
64+
controller.abort();
65+
});
66+
67+
setTimeout(() => ensureStream(response), headersTimeout);
68+
69+
return {
6070
isClosed: () => response.writableEnded || response.closed,
71+
signal: controller.signal,
6172
emit: (event, data) => {
6273
ensureStream(response);
6374
response.write(formatEvent(events, event, data), "utf-8");
@@ -67,7 +78,8 @@ export const makeMiddleware = <E extends EventsMap>(events: E) =>
6778
* */
6879
response.flush?.();
6980
},
70-
},
81+
};
82+
},
7183
});
7284

7385
export const makeResultHandler = <E extends EventsMap>(events: E) =>

express-zod-api/tests/sse.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ describe("SSE", () => {
8787
if (flushMock) responseMock.flush = flushMock;
8888
expect(output).toEqual({
8989
isClosed: expect.any(Function),
90+
signal: expect.any(AbortSignal),
9091
emit: expect.any(Function),
9192
});
9293
const { isClosed, emit } = output as Emitter<{ test: z.ZodString }>;
@@ -100,6 +101,15 @@ describe("SSE", () => {
100101
expect(isClosed()).toBeTruthy();
101102
},
102103
);
104+
105+
test("should abort signal on connection close", async () => {
106+
const middleware = makeMiddleware({ test: z.string() });
107+
const { requestMock, output } = await testMiddleware({ middleware });
108+
const { signal } = output as Emitter<{ test: z.ZodString }>;
109+
expect(signal.aborted).toBeFalsy();
110+
requestMock.emit("close");
111+
expect(signal.aborted).toBeTruthy();
112+
});
103113
});
104114

105115
describe("makeResultHandler()", () => {

0 commit comments

Comments
 (0)