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
95 changes: 48 additions & 47 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
},
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"rules": {
"no-undef": "off"
}
}
]
}
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ name: CI
on:
pull_request:
branches:
- main
- master
- develop
- 'release/v*.*.*'
push:
branches:
- main
- master
- develop
- 'release/v*.*.*'

Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/repository-github-color" /></a>
</p>

**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

Expand Down
Binary file modified assets/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions demo/index.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
9 changes: 8 additions & 1 deletion package-lock.json

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

5 changes: 3 additions & 2 deletions 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 demo/index.ts",
"typecheck": "tsc --pretty --noEmit --skipLibCheck",
"verify:prettier": "npx prettier . --check",
"verify:eslint": "npx eslint \"./**/*.{js,jsx,ts,tsx}\" --max-warnings=0",
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare namespace NodeJS {
interface ProcessEnv {
PORT?: string;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './service';
8 changes: 8 additions & 0 deletions src/service/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
export * from './request-interruption-service';

declare global {
namespace Express {
interface Request {
signal: AbortSignal;
}
}
}
1 change: 0 additions & 1 deletion src/service/request-interruption-service.constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export const abortableMetaSymbol = Symbol('Saborter.meta');
export const X_REQUEST_ID_HEADER = 'x-request-id';
47 changes: 0 additions & 47 deletions src/service/request-interruption-service.lib.ts

This file was deleted.

15 changes: 11 additions & 4 deletions src/service/request-interruption-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 { 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<string, AbortFunction | null>();
Expand Down Expand Up @@ -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', () => {
Expand All @@ -50,6 +51,12 @@ class RequestInterruptionService {
}
});

res.on('finish', () => {
if (!controller.signal.aborted) {
this.abortRegistries.delete(requestId);
}
});

next();
};
}
Expand All @@ -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' });
}
Expand Down
15 changes: 2 additions & 13 deletions src/service/request-interruption-service.utils.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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;
};
Loading