snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
This commit is contained in:
1593
packages/extension-throttle/CHANGELOG.md
Normal file
1593
packages/extension-throttle/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/extension-throttle/README.md
Normal file
48
packages/extension-throttle/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @hocuspocus/extension-throttle
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
Rate-limit and throttle connections to a [Hocuspocus](https://github.com/ueberdosis/hocuspocus) server by IP. Protects your collaboration backend from abuse, accidental reconnect loops, and misbehaving clients.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-throttle
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
With default thresholds (15 connection attempts per 60 seconds, 5 minute ban):
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
import { Throttle } from "@hocuspocus/extension-throttle"
|
||||
|
||||
const server = new Server({
|
||||
extensions: [new Throttle()],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
### Tune the limits
|
||||
|
||||
```js
|
||||
new Throttle({
|
||||
throttle: 30, // allowed connection attempts per window
|
||||
consideredSeconds: 60, // window length in seconds
|
||||
banTime: 5, // ban duration in minutes
|
||||
})
|
||||
```
|
||||
|
||||
Pass `throttle: false` (or `null`) to effectively disable the limiter without removing the extension.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full options reference: [tiptap.dev/docs/hocuspocus/server/extensions/throttle](https://tiptap.dev/docs/hocuspocus/server/extensions/throttle).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
40
packages/extension-throttle/package.json
Normal file
40
packages/extension-throttle/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-throttle",
|
||||
"version": "4.7.0",
|
||||
"description": "hocuspocus throttle extension",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"throttle",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-throttle.cjs",
|
||||
"module": "dist/hocuspocus-throttle.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-throttle.esm.js",
|
||||
"require": "./dist/hocuspocus-throttle.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/server": "workspace:^"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
126
packages/extension-throttle/src/index.ts
Normal file
126
packages/extension-throttle/src/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Extension, onConnectPayload } from "@hocuspocus/server";
|
||||
|
||||
export interface ThrottleConfiguration {
|
||||
throttle: number | null | false; // how many requests within `consideredSeconds` until we're rejecting requests (setting this to 15 means the 16th request will be rejected)
|
||||
consideredSeconds: number; // how many seconds to consider (default is last 60 seconds from the current connection attempt)
|
||||
banTime: number; // for how long to ban after receiving too many requests (in minutes!)
|
||||
cleanupInterval: number; // how often to clean up the records of IPs (this won't delete ips that are still blocked or recent enough by `consideredSeconds`)
|
||||
}
|
||||
|
||||
export class Throttle implements Extension {
|
||||
configuration: ThrottleConfiguration = {
|
||||
throttle: 15,
|
||||
banTime: 5,
|
||||
consideredSeconds: 60,
|
||||
cleanupInterval: 90,
|
||||
};
|
||||
|
||||
connectionsByIp: Map<string, Array<number>> = new Map();
|
||||
|
||||
bannedIps: Map<string, number> = new Map();
|
||||
|
||||
cleanupInterval?: NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
constructor(configuration?: Partial<ThrottleConfiguration>) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
|
||||
this.cleanupInterval = setInterval(
|
||||
this.clearMaps.bind(this),
|
||||
this.configuration.cleanupInterval * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
onDestroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public clearMaps() {
|
||||
this.connectionsByIp.forEach((value, key) => {
|
||||
const filteredValue = value.filter(
|
||||
(timestamp) =>
|
||||
timestamp + this.configuration.consideredSeconds * 1000 > Date.now(),
|
||||
);
|
||||
|
||||
if (filteredValue.length) {
|
||||
this.connectionsByIp.set(key, filteredValue);
|
||||
} else {
|
||||
this.connectionsByIp.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
this.bannedIps.forEach((value, key) => {
|
||||
if (!this.isBanned(key)) {
|
||||
this.bannedIps.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isBanned(ip: string) {
|
||||
const bannedAt = this.bannedIps.get(ip) || 0;
|
||||
return Date.now() < bannedAt + this.configuration.banTime * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle requests
|
||||
* @private
|
||||
*/
|
||||
private throttle(ip: string): boolean {
|
||||
if (!this.configuration.throttle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isBanned(ip)) return true;
|
||||
|
||||
this.bannedIps.delete(ip);
|
||||
|
||||
// add this connection try to the list of previous connections
|
||||
const previousConnections = this.connectionsByIp.get(ip) || [];
|
||||
previousConnections.push(Date.now());
|
||||
|
||||
// calculate the previous connections in the last considered time interval
|
||||
const previousConnectionsInTheConsideredInterval =
|
||||
previousConnections.filter(
|
||||
(timestamp) =>
|
||||
timestamp + this.configuration.consideredSeconds * 1000 > Date.now(),
|
||||
);
|
||||
|
||||
this.connectionsByIp.set(ip, previousConnectionsInTheConsideredInterval);
|
||||
|
||||
if (
|
||||
previousConnectionsInTheConsideredInterval.length >
|
||||
this.configuration.throttle
|
||||
) {
|
||||
this.bannedIps.set(ip, Date.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* onConnect hook
|
||||
* @param data
|
||||
*/
|
||||
onConnect(data: onConnectPayload): Promise<any> {
|
||||
const { request } = data;
|
||||
|
||||
// get the remote ip address from headers (web standard Request doesn't have socket)
|
||||
const ip =
|
||||
request.headers.get("x-real-ip") ||
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
"";
|
||||
|
||||
// throttle the connection
|
||||
return this.throttle(<string>ip) ? Promise.reject() : Promise.resolve();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user