diff --git a/.eslintrc b/.eslintrc
index 2d87807..19cda9e 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,51 +1,52 @@
{
- "root": true,
- "env": { "browser": true, "es2020": true, "jest": true },
- "extends": ["eslint:recommended", "airbnb", "plugin:import/typescript", "plugin:prettier/recommended"],
- "parser": "@typescript-eslint/parser",
- "ignorePatterns": [
- "**/dist/",
- "**/node_modules/",
- "**/*.json",
- "**/*.html",
- "/**/coverage/",
- "**/integrations/**/",
- "./vite.config.js"
- ],
- "plugins": ["@typescript-eslint", "prettier"],
- "rules": {
- "no-continue": "off",
- "quotes": "off",
- "import/prefer-default-export": ["off"],
- "class-methods-use-this": ["off"],
- "func-names": ["off"],
- "no-plusplus": ["off"],
- "prefer-spread": ["off"],
- "consistent-return": ["off"],
- "newline-before-return": "warn",
- "default-case": ["off"],
- "comma-dangle": ["off"],
- "import/extensions": ["off"],
- "max-len": ["off"],
- "no-console": ["warn", { "allow": ["warn", "error"] }],
- "@typescript-eslint/lines-between-class-members": ["off", {}],
- "import/no-extraneous-dependencies": ["off"],
- "@typescript-eslint/no-unused-vars": ["off", {}],
- "no-unused-vars": ["off"],
- "prettier/prettier": [
- "error",
- {
- "arrowParens": "always",
- "endOfLine": "auto"
- }
- ]
- },
- "overrides": [
+ "root": true,
+ "env": { "browser": true, "es2020": true, "jest": true },
+ "extends": ["eslint:recommended", "airbnb", "plugin:import/typescript", "plugin:prettier/recommended"],
+ "parser": "@typescript-eslint/parser",
+ "ignorePatterns": [
+ "**/dist/",
+ "**/node_modules/",
+ "**/*.json",
+ "**/*.html",
+ "/**/coverage/",
+ "**/integrations/**/",
+ "./vite.config.js"
+ ],
+ "plugins": ["@typescript-eslint", "prettier"],
+ "rules": {
+ "no-continue": "off",
+ "quotes": "off",
+ "import/prefer-default-export": ["off"],
+ "class-methods-use-this": ["off"],
+ "func-names": ["off"],
+ "no-plusplus": ["off"],
+ "prefer-spread": ["off"],
+ "consistent-return": ["off"],
+ "newline-before-return": "warn",
+ "default-case": ["off"],
+ "comma-dangle": ["off"],
+ "import/extensions": ["off"],
+ "max-len": ["off"],
+ "no-console": ["warn", { "allow": ["warn", "error"] }],
+ "@typescript-eslint/lines-between-class-members": ["off", {}],
+ "import/no-extraneous-dependencies": ["off"],
+ "@typescript-eslint/no-unused-vars": ["off", {}],
+ "no-unused-vars": ["off"],
+ "import/no-unresolved": "off",
+ "prettier/prettier": [
+ "error",
{
- "files": ["*.ts", "*.tsx"],
- "rules": {
- "no-undef": "off"
- }
+ "arrowParens": "always",
+ "endOfLine": "auto"
}
]
- }
\ No newline at end of file
+ },
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {
+ "no-undef": "off"
+ }
+ }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 70c6dff..ff0be5a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,12 +3,12 @@ name: CI
on:
pull_request:
branches:
- - main
+ - master
- develop
- 'release/v*.*.*'
push:
branches:
- - main
+ - master
- develop
- 'release/v*.*.*'
diff --git a/.husky/pre-commit b/.husky/pre-push
similarity index 100%
rename from .husky/pre-commit
rename to .husky/pre-push
diff --git a/README.md b/README.md
index a9ec0ff..823a137 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-**Saborter Server** – A lightweight [Node.js](https://nodejs.org/en) library that automatically cancels server-side operations when the client aborts a request. Built on the standard [AbortController API](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), it seamlessly with [Express](https://expressjs.com/) framework and allows you to stop unnecessary database queries, file writes, or external API calls, saving server resources and improving scalability. Fully tree‑shakeable – only the code you actually use ends up in your bundle.
+**Saborter Server** a lightweight [Node.js](https://nodejs.org/en) library that automatically cancels server-side operations when the client aborts a request. Built on the standard [AbortController API](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), it seamlessly with [Express](https://expressjs.com/) framework and allows you to stop unnecessary database queries, file writes, or external API calls, saving server resources and improving scalability. Fully tree‑shakeable – only the code you actually use ends up in your bundle.
## 📚 Documentation
diff --git a/assets/logo.png b/assets/logo.png
index 8f262f2..80d21b9 100644
Binary files a/assets/logo.png and b/assets/logo.png differ
diff --git a/demo/index.ts b/demo/index.ts
new file mode 100644
index 0000000..a8e69ef
--- /dev/null
+++ b/demo/index.ts
@@ -0,0 +1,58 @@
+import express from 'express';
+import cors from 'cors';
+import { isAbortError } from 'saborter/lib';
+import { initRequestInterruptionService } from '../src';
+
+const app = express();
+// eslint-disable-next-line dot-notation
+const port = process.env['PORT'] || 3000;
+
+initRequestInterruptionService(app);
+
+app.use(cors());
+app.use(express.json());
+
+const longRunningOperation = async (signal?: AbortSignal | null) => {
+ return new Promise((resolve, reject) => {
+ // eslint-disable-next-line no-console
+ console.log('Work...');
+ const timeout = setTimeout(() => {
+ // reject(new Error('!!!'));
+
+ // eslint-disable-next-line no-console
+ console.log('Done!');
+ resolve({ done: true });
+ }, 10_000);
+
+ signal?.addEventListener(
+ 'abort',
+ () => {
+ clearTimeout(timeout);
+ const error = new Error('Operation cancelled');
+ error.name = 'AbortError';
+
+ reject(error);
+ },
+ { once: true }
+ );
+ });
+};
+
+app.get('/', async (req, res) => {
+ try {
+ const result = await longRunningOperation(req.signal);
+
+ res.json(result);
+ } catch (error) {
+ if (isAbortError(error)) {
+ return res.status(499);
+ }
+
+ res.status(500).send();
+ }
+});
+
+app.listen(port, () => {
+ // eslint-disable-next-line no-console
+ console.log(`Server running at http://localhost:${port}`);
+});
diff --git a/package-lock.json b/package-lock.json
index e56a650..fcf2446 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
- "express": "^5.2.1"
+ "express": "^5.2.1",
+ "saborter": "^2.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
@@ -5782,6 +5783,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/saborter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/saborter/-/saborter-2.1.0.tgz",
+ "integrity": "sha512-fa4jKMskpkGGYInvdRI6xYRmJY4TUIlbbGvKuRzrUjVISgnuBDhmLQfsjVu2ZTnPD6RF7hrRlu8ydLBn6g1ybw==",
+ "license": "MIT"
+ },
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
diff --git a/package.json b/package.json
index af840bc..53ddf98 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
- "dev": "nodemon src/index.ts",
+ "dev": "nodemon demo/index.ts",
"typecheck": "tsc --pretty --noEmit --skipLibCheck",
"verify:prettier": "npx prettier . --check",
"verify:eslint": "npx eslint \"./**/*.{js,jsx,ts,tsx}\" --max-warnings=0",
@@ -21,7 +21,8 @@
"license": "ISC",
"description": "",
"dependencies": {
- "express": "^5.2.1"
+ "express": "^5.2.1",
+ "saborter": "^2.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
diff --git a/src/global.d.ts b/src/global.d.ts
new file mode 100644
index 0000000..1e9a3fd
--- /dev/null
+++ b/src/global.d.ts
@@ -0,0 +1,5 @@
+declare namespace NodeJS {
+ interface ProcessEnv {
+ PORT?: string;
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index e69de29..f78beab 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -0,0 +1 @@
+export * from './service';
diff --git a/src/service/index.ts b/src/service/index.ts
index ca773aa..a0831f6 100644
--- a/src/service/index.ts
+++ b/src/service/index.ts
@@ -1 +1,9 @@
export * from './request-interruption-service';
+
+declare global {
+ namespace Express {
+ interface Request {
+ signal: AbortSignal;
+ }
+ }
+}
diff --git a/src/service/request-interruption-service.constants.ts b/src/service/request-interruption-service.constants.ts
index 5c2923d..fa9f70a 100644
--- a/src/service/request-interruption-service.constants.ts
+++ b/src/service/request-interruption-service.constants.ts
@@ -1,2 +1 @@
-export const abortableMetaSymbol = Symbol('Saborter.meta');
export const X_REQUEST_ID_HEADER = 'x-request-id';
diff --git a/src/service/request-interruption-service.lib.ts b/src/service/request-interruption-service.lib.ts
deleted file mode 100644
index fd4775a..0000000
--- a/src/service/request-interruption-service.lib.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { Request, Response } from 'express';
-import { getRequestMeta } from './request-interruption-service.utils';
-import { WithAbortableConfig } from './request-interruption-service.types';
-
-export const getAbortSignal = (req: Request): AbortSignal | null => {
- return getRequestMeta(req).signal ?? null;
-};
-
-export const withAbortable = (
- handler: (req: Request, res: Response) => Promise,
- { isErrorNativeBehavior = false }: WithAbortableConfig = {}
-) => {
- return async (req: Request, res: Response) => {
- const signal = getAbortSignal(req);
-
- if (!signal) return handler(req, res);
-
- let currentReject: ((reason?: any) => void) | null = null;
-
- const listener = () => currentReject?.(signal.reason);
-
- try {
- await Promise.race([
- handler(req, res),
- new Promise((_, reject) => {
- currentReject = reject;
-
- signal.addEventListener('abort', listener, {
- once: true
- });
- })
- ]);
- } catch (err) {
- if (isErrorNativeBehavior) {
- throw err;
- }
- } finally {
- signal.removeEventListener('abort', listener);
- }
- };
-};
-
-export const abort = (req: Request, reason?: any): void => {
- const controller = getRequestMeta(req).controller ?? new AbortController();
-
- controller.abort(reason);
-};
diff --git a/src/service/request-interruption-service.ts b/src/service/request-interruption-service.ts
index 262fd54..f76d398 100644
--- a/src/service/request-interruption-service.ts
+++ b/src/service/request-interruption-service.ts
@@ -1,6 +1,7 @@
import { Request, Response, NextFunction, Express } from 'express';
+import { AbortError } from 'saborter/errors';
import { AbortFunction } from './request-interruption-service.types';
-import { getRequestId, setRequestMeta } from './request-interruption-service.utils';
+import { getRequestId, setExpressMetadata } from './request-interruption-service.utils';
class RequestInterruptionService {
private abortRegistries = new Map();
@@ -38,10 +39,10 @@ class RequestInterruptionService {
const controller = new AbortController();
- setRequestMeta(req, controller);
+ setExpressMetadata(req, controller);
this.registerAbortableFunction(requestId, () => {
- controller.abort(new Error('AbortError'));
+ controller.abort(new AbortError('The endpoint was interrupted', { initiator: 'server' }));
});
req.on('close', () => {
@@ -50,6 +51,12 @@ class RequestInterruptionService {
}
});
+ res.on('finish', () => {
+ if (!controller.signal.aborted) {
+ this.abortRegistries.delete(requestId);
+ }
+ });
+
next();
};
}
@@ -66,7 +73,7 @@ export const initRequestInterruptionService = (
const requestId = getRequestId(req);
if (requestId && requestInterruptionService.abort(requestId)) {
- res.status(499).json({ cancelled: true });
+ res.status(200).json({ aborted: true });
} else {
res.status(404).json({ error: 'Request not found' });
}
diff --git a/src/service/request-interruption-service.utils.ts b/src/service/request-interruption-service.utils.ts
index ba04c31..d79e579 100644
--- a/src/service/request-interruption-service.utils.ts
+++ b/src/service/request-interruption-service.utils.ts
@@ -1,5 +1,4 @@
import { Request } from 'express';
-import { InterruptibleRequestMeta } from './request-interruption-service.types';
import * as Constants from './request-interruption-service.constants';
export const getRequestId = (req: Request): string => {
@@ -8,16 +7,6 @@ export const getRequestId = (req: Request): string => {
return requestId?.[0] ?? '';
};
-export const getRequestMeta = (req: Request): InterruptibleRequestMeta => {
- return (req as any)[Constants.abortableMetaSymbol];
-};
-
-export const setRequestMeta = (req: Request, controller: AbortController): void => {
- const requestId = getRequestId(req);
-
- (req as any)[Constants.abortableMetaSymbol] = {
- requestId,
- controller,
- signal: controller.signal
- } as InterruptibleRequestMeta;
+export const setExpressMetadata = (req: Request, controller: AbortController): void => {
+ (req as any).signal = controller.signal;
};