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; };