Skip to content

Commit 5d7fe06

Browse files
committed
feat: adds an API for integration with Express (#3)
1 parent e11ed04 commit 5d7fe06

9 files changed

Lines changed: 608 additions & 77 deletions

package-lock.json

Lines changed: 448 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@
4545
},
4646
"lint-staged": {
4747
"*.{js,jsx,ts,tsx}": [
48-
"npm run fix:prettier"
48+
"npm run format:prettier"
4949
],
5050
"*.{json,md}": [
51-
"npm run fix:prettier"
51+
"npm run format:prettier"
5252
]
5353
}
5454
}

src/service/abortable-service.ts

Lines changed: 0 additions & 74 deletions
This file was deleted.

src/service/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from './abortable-service';
1+
export * from './request-interruption-service';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const abortableMetaSymbol = Symbol('Saborter.meta');
2+
export const X_REQUEST_ID_HEADER = 'x-request-id';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Request, Response } from 'express';
2+
import { getRequestMeta } from './request-interruption-service.utils';
3+
import { WithAbortableConfig } from './request-interruption-service.types';
4+
5+
export const getAbortSignal = (req: Request): AbortSignal | null => {
6+
return getRequestMeta(req).signal ?? null;
7+
};
8+
9+
export const withAbortable = (
10+
handler: (req: Request, res: Response) => Promise<void>,
11+
{ isErrorNativeBehavior = false }: WithAbortableConfig = {}
12+
) => {
13+
return async (req: Request, res: Response) => {
14+
const signal = getAbortSignal(req);
15+
16+
if (!signal) return handler(req, res);
17+
18+
let currentReject: ((reason?: any) => void) | null = null;
19+
20+
const listener = () => currentReject?.(signal.reason);
21+
22+
try {
23+
await Promise.race([
24+
handler(req, res),
25+
new Promise((_, reject) => {
26+
currentReject = reject;
27+
28+
signal.addEventListener('abort', listener, {
29+
once: true
30+
});
31+
})
32+
]);
33+
} catch (err) {
34+
if (isErrorNativeBehavior) {
35+
throw err;
36+
}
37+
} finally {
38+
signal.removeEventListener('abort', listener);
39+
}
40+
};
41+
};
42+
43+
export const abort = (req: Request, reason?: any): void => {
44+
const controller = getRequestMeta(req).controller ?? new AbortController();
45+
46+
controller.abort(reason);
47+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Request, Response, NextFunction, Express } from 'express';
2+
import { AbortFunction } from './request-interruption-service.types';
3+
import { getRequestId, setRequestMeta } from './request-interruption-service.utils';
4+
5+
class RequestInterruptionService {
6+
private abortRegistries = new Map<string, AbortFunction | null>();
7+
8+
private registerAbortableFunction = (requestId: string, abortFn: AbortFunction): void => {
9+
this.abortRegistries.set(requestId, abortFn);
10+
};
11+
12+
public abort = (requestId: string, { deletable = true }: { deletable?: boolean } = {}): boolean => {
13+
const abortFn = this.abortRegistries.get(requestId) ?? null;
14+
15+
if (abortFn) {
16+
abortFn();
17+
18+
if (deletable) {
19+
this.abortRegistries.delete(requestId);
20+
}
21+
22+
return true;
23+
}
24+
25+
return false;
26+
};
27+
28+
public expressMiddleware = (req: Request, res: Response, next: NextFunction): void => {
29+
const requestId = getRequestId(req);
30+
31+
if (!requestId) {
32+
return next();
33+
}
34+
35+
if (this.abortRegistries.has(requestId)) {
36+
this.abort(requestId, { deletable: false });
37+
}
38+
39+
const controller = new AbortController();
40+
41+
setRequestMeta(req, controller);
42+
43+
this.registerAbortableFunction(requestId, () => {
44+
controller.abort(new Error('AbortError'));
45+
});
46+
47+
req.on('close', () => {
48+
if (!res.writableEnded) {
49+
this.abort(requestId);
50+
}
51+
});
52+
53+
next();
54+
};
55+
}
56+
57+
export const initRequestInterruptionService = (
58+
app: Express,
59+
{ basePath = '', endpointName = '/api/cancel' }: { basePath?: string; endpointName?: string } = {}
60+
) => {
61+
const requestInterruptionService = new RequestInterruptionService();
62+
63+
app.use(requestInterruptionService.expressMiddleware);
64+
65+
app.post(`${basePath}${endpointName}`, (req, res) => {
66+
const requestId = getRequestId(req);
67+
68+
if (requestId && requestInterruptionService.abort(requestId)) {
69+
res.status(499).json({ cancelled: true });
70+
} else {
71+
res.status(404).json({ error: 'Request not found' });
72+
}
73+
});
74+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export type AbortFunction = () => void;
2+
3+
export interface InterruptibleRequestMeta {
4+
requestId: string;
5+
controller: AbortController;
6+
signal: AbortSignal;
7+
}
8+
9+
export interface WithAbortableConfig {
10+
isErrorNativeBehavior?: boolean;
11+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Request } from 'express';
2+
import { InterruptibleRequestMeta } from './request-interruption-service.types';
3+
import * as Constants from './request-interruption-service.constants';
4+
5+
export const getRequestId = (req: Request): string => {
6+
const requestId = req.headers[Constants.X_REQUEST_ID_HEADER]?.toString().split(',');
7+
8+
return requestId?.[0] ?? '';
9+
};
10+
11+
export const getRequestMeta = (req: Request): InterruptibleRequestMeta => {
12+
return (req as any)[Constants.abortableMetaSymbol];
13+
};
14+
15+
export const setRequestMeta = (req: Request, controller: AbortController): void => {
16+
const requestId = getRequestId(req);
17+
18+
(req as any)[Constants.abortableMetaSymbol] = {
19+
requestId,
20+
controller,
21+
signal: controller.signal
22+
} as InterruptibleRequestMeta;
23+
};

0 commit comments

Comments
 (0)