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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<img src="https://github.com/TENSIILE/saborter-server/actions/workflows/publish.yml/badge.svg" /></a>
<a href="https://github.com/TENSIILE/saborter-server/actions/workflows/ci.yml" alt="CI">
<img src="https://github.com/TENSIILE/saborter-server/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/TENSIILE/saborter-server/blob/develop/LICENSE" alt="License">
<a href="https://github.com/TENSIILE/saborter-server/blob/develop/LICENSE.md" alt="License">
<img src="https://img.shields.io/badge/license-MIT-blue" /></a>
<a href="https://github.com/TENSIILE/saborter-server" alt="Github">
<img src="https://img.shields.io/badge/repository-github-color" /></a>
Expand Down Expand Up @@ -46,7 +46,7 @@ import { initRequestInterrupts, getAbortSignal } from '@saborter/server/express'
const app = express();
const port = process.env.PORT || 3000;

initRequestInterrupts(app, { endpointName: '/api/abort' });
initRequestInterrupts(app);
app.use(express.json());

app.get('/', async (req, res) => {
Expand Down
4 changes: 2 additions & 2 deletions demo/express/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions demo/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"dev": "nodemon src/index.ts"
"dev": "nodemon src/index.ts -watch"
},
"keywords": [],
"author": "",
Expand All @@ -14,7 +14,7 @@
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"@saborter/server": "file:./saborter-server-1.0.0.tgz"
"@saborter/server": "file:./saborter-server-1.0.1.tgz"
},
"devDependencies": {
"@types/express": "^5.0.6",
Expand Down
Binary file removed demo/express/saborter-server-1.0.0.tgz
Binary file not shown.
Binary file added demo/express/saborter-server-1.0.1.tgz
Binary file not shown.
2 changes: 1 addition & 1 deletion demo/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { initRequestInterrupts, getAbortSignal } from '@saborter/server/express'
const app = express();
const port = process.env['PORT'] || 3000;

initRequestInterrupts(app, { endpointName: '/api/cancel' });
initRequestInterrupts(app);

app.use(cors());
app.use(express.json());
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@saborter/server",
"version": "1.0.0",
"version": "1.0.1",
"description": "Lightweight, tree‑shakeable utility to cancel server tasks on client abort",
"main": "dist/express.cjs.js",
"module": "dist/express.es.js",
Expand Down
1 change: 1 addition & 0 deletions src/packages/frameworks/express/express.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ENDPOINT_WAS_INTERRUPTED_MESSAGE = 'The endpoint was interrupted';
59 changes: 5 additions & 54 deletions src/packages/frameworks/express/express.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction, Express } from 'express';
import { AbortError } from 'saborter/errors';
import { setSignalInExpressRequest } from './express.utils';
import * as Constants from './express.constants';
import * as Shared from '../../../shared';

/**
Expand Down Expand Up @@ -76,7 +77,7 @@ class ExpressRequestInterruptionService {
setSignalInExpressRequest(req, controller);

this.registerAbortableFunction(requestId, () => {
controller.abort(new AbortError('The endpoint was interrupted', { initiator: 'server' }));
controller.abort(new AbortError(Constants.ENDPOINT_WAS_INTERRUPTED_MESSAGE, { initiator: 'server' }));
});

req.on('close', () => {
Expand All @@ -85,6 +86,7 @@ class ExpressRequestInterruptionService {
}
});

// Triggers clearing of request id when request completes or fails, excluding the case of abort.
res.on('finish', () => {
if (!controller.signal.aborted) {
this.abortRegistries.delete(requestId);
Expand All @@ -100,65 +102,14 @@ class ExpressRequestInterruptionService {
*
* This function:
* - Adds a middleware that attaches an `AbortSignal` to every request.
* - Adds a POST endpoint (default `/api/cancel`) that can be called to abort a
* specific request by sending its request ID in the request body.
*
* @param app - Express application instance.
* @param options - Configuration options.
* @param options.endpointName - The path where the abort endpoint will be mounted.
* Defaults to `/api/cancel`.

* @returns An `ExpressRequestInterruptionService` instance, which can be used
* to manually abort requests if needed.
*/
export const initRequestInterrupts = (
app: Express,
{ endpointName = '/api/cancel' }: { endpointName?: string } = {}
) => {
export const initRequestInterrupts = (app: Express): void => {
const requestInterruptionService = new ExpressRequestInterruptionService();

app.use(requestInterruptionService.expressMiddleware);

app.post(`${endpointName}`, async (req, res) => {
const requestId = await new Promise<string | undefined>((resolve) => {
let rawBody = '';

req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
resolve(rawBody);
});
});

if (requestId && requestInterruptionService.abort(requestId)) {
res.status(200).json({ aborted: true });
} else {
res.status(404).json({ error: 'Request not found' });
}
});
};

/**
* Retrieves the `signal` property from an Express Request object.
*
* @param {import('express').Request} req - The Express request object.
* @returns {AbortSignal | undefined} The abort signal attached to the request,
* or `undefined` if the property does not exist.
*
* @example
* // In a route handler
* app.get('/data', (req, res) => {
* const signal = getAbortSignal(req);
* fetch('https://api.example.com/data', { signal })
* .then(response => response.json())
* .then(data => res.json(data))
* .catch(err => {
* if (err.name === 'AbortError') {
* res.status(499).end();
* } else {
* res.status(500).end();
* }
* });
* });
*/
export const getAbortSignal = (req: Request): AbortSignal | undefined => (req as any).signal;
25 changes: 25 additions & 0 deletions src/packages/frameworks/express/express.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,28 @@ import { Request } from 'express';
export const setSignalInExpressRequest = (req: Request, controller: AbortController): void => {
(req as any).signal = controller.signal;
};

/**
* Retrieves the `signal` property from an Express Request object.
*
* @param {import('express').Request} req - The Express request object.
* @returns {AbortSignal | undefined} The abort signal attached to the request,
* or `undefined` if the property does not exist.
*
* @example
* // In a route handler
* app.get('/data', (req, res) => {
* const signal = getAbortSignal(req);
* fetch('https://api.example.com/data', { signal })
* .then(response => response.json())
* .then(data => res.json(data))
* .catch(err => {
* if (err.name === 'AbortError') {
* res.status(499).end();
* } else {
* res.status(500).end();
* }
* });
* });
*/
export const getAbortSignal = (req: Request): AbortSignal | undefined => (req as any).signal;
1 change: 1 addition & 0 deletions src/packages/frameworks/express/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './express.service';
export { getAbortSignal } from './express.utils';
4 changes: 2 additions & 2 deletions src/shared/utils/get-request-id/get-request-id.util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Constants from './get-request-id.constants';

export const getRequestId = <T extends { headers: { [key: string]: unknown } }>(req: T): string => {
const requestId = req.headers[Constants.X_REQUEST_ID_HEADER]?.toString().split(',');
const requestId = req.headers[Constants.X_REQUEST_ID_HEADER]?.toString();

return requestId?.[0] ?? '';
return requestId ?? '';
};
Loading