@kwirthmagnify/kwirth-sender-ratelimit 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/back.js +125 -0
- package/front.js +201 -0
- package/package.json +7 -0
package/back.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/back/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
RatelimitSender: () => RatelimitSender,
|
|
24
|
+
default: () => index_default
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
var UNIT_MS = { sec: 1e3, min: 6e4, hour: 36e5, day: 864e5 };
|
|
28
|
+
var RatelimitSender = class {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.id = "ratelimit";
|
|
31
|
+
this.senderType = "filter";
|
|
32
|
+
this.configs = /* @__PURE__ */ new Map();
|
|
33
|
+
this.states = /* @__PURE__ */ new Map();
|
|
34
|
+
}
|
|
35
|
+
addConfig(config) {
|
|
36
|
+
const rc = config;
|
|
37
|
+
if (!rc.limit) rc.limit = 10;
|
|
38
|
+
if (!rc.interval) rc.interval = 1;
|
|
39
|
+
if (!rc.unit) rc.unit = "min";
|
|
40
|
+
this.configs.set(rc.name, rc);
|
|
41
|
+
}
|
|
42
|
+
removeConfig(name) {
|
|
43
|
+
const state = this.states.get(name);
|
|
44
|
+
if (state?.flushTimer) clearTimeout(state.flushTimer);
|
|
45
|
+
this.states.delete(name);
|
|
46
|
+
this.configs.delete(name);
|
|
47
|
+
}
|
|
48
|
+
hasConfig(name) {
|
|
49
|
+
return this.configs.has(name);
|
|
50
|
+
}
|
|
51
|
+
getConfigNames() {
|
|
52
|
+
return Array.from(this.configs.keys());
|
|
53
|
+
}
|
|
54
|
+
getNodeMeta() {
|
|
55
|
+
return { label: "Rate limit", icon: "Speed", description: "Limits message delivery rate. Excess messages are queued and delivered in the next time window." };
|
|
56
|
+
}
|
|
57
|
+
async send(_configName, _message) {
|
|
58
|
+
}
|
|
59
|
+
async evalFilter(configName, _message, forward) {
|
|
60
|
+
const config = this.configs.get(configName);
|
|
61
|
+
if (!config) return;
|
|
62
|
+
const intervalMs = config.interval * (UNIT_MS[config.unit] ?? 6e4);
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
let state = this.states.get(configName);
|
|
65
|
+
if (!state) {
|
|
66
|
+
state = { count: 0, windowStart: now, queue: [], flushTimer: void 0 };
|
|
67
|
+
this.states.set(configName, state);
|
|
68
|
+
}
|
|
69
|
+
if (now - state.windowStart >= intervalMs) {
|
|
70
|
+
state.count = 0;
|
|
71
|
+
state.windowStart = now;
|
|
72
|
+
}
|
|
73
|
+
if (state.count < config.limit) {
|
|
74
|
+
state.count++;
|
|
75
|
+
await forward();
|
|
76
|
+
} else {
|
|
77
|
+
state.queue.push(forward);
|
|
78
|
+
if (!state.flushTimer) {
|
|
79
|
+
const remaining = intervalMs - (now - state.windowStart);
|
|
80
|
+
state.flushTimer = setTimeout(() => {
|
|
81
|
+
this.flush(configName);
|
|
82
|
+
}, remaining);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async flush(configName) {
|
|
87
|
+
const state = this.states.get(configName);
|
|
88
|
+
if (!state) return;
|
|
89
|
+
state.flushTimer = void 0;
|
|
90
|
+
const config = this.configs.get(configName);
|
|
91
|
+
if (!config) return;
|
|
92
|
+
const intervalMs = config.interval * (UNIT_MS[config.unit] ?? 6e4);
|
|
93
|
+
state.count = 0;
|
|
94
|
+
state.windowStart = Date.now();
|
|
95
|
+
const toFlush = state.queue.splice(0, config.limit);
|
|
96
|
+
state.count = toFlush.length;
|
|
97
|
+
if (state.queue.length > 0) {
|
|
98
|
+
state.flushTimer = setTimeout(() => {
|
|
99
|
+
this.flush(configName);
|
|
100
|
+
}, intervalMs);
|
|
101
|
+
}
|
|
102
|
+
for (const fwd of toFlush) await fwd();
|
|
103
|
+
}
|
|
104
|
+
getConfigSchema() {
|
|
105
|
+
return [
|
|
106
|
+
{ name: "name", label: "Name", required: true },
|
|
107
|
+
{ name: "limit", label: "Limit", type: "number" },
|
|
108
|
+
{ name: "interval", label: "Interval", type: "number" },
|
|
109
|
+
{ name: "unit", label: "Unit", type: "select", options: ["sec", "min", "hour", "day"] }
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
async startSender(_senders) {
|
|
113
|
+
}
|
|
114
|
+
async stopSender() {
|
|
115
|
+
for (const state of this.states.values()) {
|
|
116
|
+
if (state.flushTimer) clearTimeout(state.flushTimer);
|
|
117
|
+
}
|
|
118
|
+
this.states.clear();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var index_default = RatelimitSender;
|
|
122
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
123
|
+
0 && (module.exports = {
|
|
124
|
+
RatelimitSender
|
|
125
|
+
});
|
package/front.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
10
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
|
|
29
|
+
// kwirth-globals:react
|
|
30
|
+
var require_react = __commonJS({
|
|
31
|
+
"kwirth-globals:react"(exports, module) {
|
|
32
|
+
module.exports = window.__kwirth__.React;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// kwirth-globals:@mui/material
|
|
37
|
+
var require_material = __commonJS({
|
|
38
|
+
"kwirth-globals:@mui/material"(exports, module) {
|
|
39
|
+
module.exports = window.__kwirth__.MUI.material;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// kwirth-globals:@mui/icons-material
|
|
44
|
+
var require_icons_material = __commonJS({
|
|
45
|
+
"kwirth-globals:@mui/icons-material"(exports, module) {
|
|
46
|
+
module.exports = window.__kwirth__.MUI.icons;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// src/front/RatelimitConfigDialog.tsx
|
|
51
|
+
var import_react = __toESM(require_react(), 1);
|
|
52
|
+
var import_material = __toESM(require_material(), 1);
|
|
53
|
+
var import_icons_material = __toESM(require_icons_material(), 1);
|
|
54
|
+
function authGet(token) {
|
|
55
|
+
return { headers: { Authorization: token ? `Bearer ${token}` : "", "X-Kwirth-App": "true" } };
|
|
56
|
+
}
|
|
57
|
+
function authPost(token, body) {
|
|
58
|
+
return { method: "POST", body, headers: { Authorization: token ? `Bearer ${token}` : "", "X-Kwirth-App": "true", "Content-Type": "application/json" } };
|
|
59
|
+
}
|
|
60
|
+
function authDelete(token) {
|
|
61
|
+
return { method: "DELETE", headers: { Authorization: token ? `Bearer ${token}` : "", "X-Kwirth-App": "true" } };
|
|
62
|
+
}
|
|
63
|
+
var empty = () => ({ name: "", limit: 10, interval: 1, unit: "min" });
|
|
64
|
+
var UNIT_LABELS = { sec: "Seconds", min: "Minutes", hour: "Hours", day: "Days" };
|
|
65
|
+
var RatelimitConfigDialog = ({ onClose, backendUrl, accessString }) => {
|
|
66
|
+
const [configs, setConfigs] = (0, import_react.useState)([]);
|
|
67
|
+
const [selectedName, setSelectedName] = (0, import_react.useState)();
|
|
68
|
+
const [originalName, setOriginalName] = (0, import_react.useState)();
|
|
69
|
+
const [edit, setEdit] = (0, import_react.useState)(empty());
|
|
70
|
+
const [saving, setSaving] = (0, import_react.useState)(false);
|
|
71
|
+
const [deleting, setDeleting] = (0, import_react.useState)();
|
|
72
|
+
const [error, setError] = (0, import_react.useState)();
|
|
73
|
+
(0, import_react.useEffect)(() => {
|
|
74
|
+
loadConfigs();
|
|
75
|
+
}, []);
|
|
76
|
+
const loadConfigs = async () => {
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`${backendUrl}/senders/ratelimit/configs`, authGet(accessString));
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
const data = await res.json();
|
|
81
|
+
setConfigs(Array.isArray(data) ? data : data.configs ?? []);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const selectConfig = (cfg) => {
|
|
87
|
+
setSelectedName(cfg.name);
|
|
88
|
+
setOriginalName(cfg.name);
|
|
89
|
+
setEdit({ ...cfg });
|
|
90
|
+
setError(void 0);
|
|
91
|
+
};
|
|
92
|
+
const newConfig = () => {
|
|
93
|
+
setSelectedName(void 0);
|
|
94
|
+
setOriginalName(void 0);
|
|
95
|
+
setEdit(empty());
|
|
96
|
+
setError(void 0);
|
|
97
|
+
};
|
|
98
|
+
const cloneConfig = () => {
|
|
99
|
+
if (!selectedName) return;
|
|
100
|
+
const base = configs.find((c) => c.name === selectedName);
|
|
101
|
+
if (!base) return;
|
|
102
|
+
setSelectedName(void 0);
|
|
103
|
+
setOriginalName(void 0);
|
|
104
|
+
setEdit({ ...base, name: `${base.name} (copy)` });
|
|
105
|
+
setError(void 0);
|
|
106
|
+
};
|
|
107
|
+
const save = async () => {
|
|
108
|
+
const trimmed = edit.name.trim();
|
|
109
|
+
if (!trimmed) return;
|
|
110
|
+
setSaving(true);
|
|
111
|
+
setError(void 0);
|
|
112
|
+
try {
|
|
113
|
+
const payload = { ...edit, name: trimmed };
|
|
114
|
+
const res = await fetch(`${backendUrl}/senders/ratelimit/configs`, authPost(accessString, JSON.stringify(payload)));
|
|
115
|
+
if (!res.ok) throw new Error((await res.json()).error ?? `HTTP ${res.status}`);
|
|
116
|
+
if (originalName && originalName !== trimmed) {
|
|
117
|
+
await fetch(`${backendUrl}/senders/ratelimit/configs/${encodeURIComponent(originalName)}`, authDelete(accessString));
|
|
118
|
+
}
|
|
119
|
+
setSelectedName(trimmed);
|
|
120
|
+
setOriginalName(trimmed);
|
|
121
|
+
await loadConfigs();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
setError(`Save failed: ${err}`);
|
|
124
|
+
} finally {
|
|
125
|
+
setSaving(false);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const deleteConfig = async (name) => {
|
|
129
|
+
setDeleting(name);
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(`${backendUrl}/senders/ratelimit/configs/${encodeURIComponent(name)}`, authDelete(accessString));
|
|
132
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
133
|
+
if (selectedName === name) newConfig();
|
|
134
|
+
await loadConfigs();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
setError(`Delete failed: ${err}`);
|
|
137
|
+
} finally {
|
|
138
|
+
setDeleting(void 0);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const isEditing = !!selectedName;
|
|
142
|
+
return /* @__PURE__ */ import_react.default.createElement(import_material.Dialog, { open: true, maxWidth: false, sx: { "& .MuiDialog-paper": { width: "620px", height: "480px" } } }, /* @__PURE__ */ import_react.default.createElement(import_material.DialogTitle, null, "Configure: Rate Limit Sender"), /* @__PURE__ */ import_react.default.createElement(import_material.DialogContent, { sx: { display: "flex", gap: 2, p: "16px !important", overflow: "hidden", height: "100%" } }, /* @__PURE__ */ import_react.default.createElement(import_material.Box, { sx: { width: 190, display: "flex", flexDirection: "column", gap: 1, flexShrink: 0 } }, /* @__PURE__ */ import_react.default.createElement(import_material.Typography, { variant: "caption", color: "text.secondary", fontWeight: "bold" }, "Configs"), /* @__PURE__ */ import_react.default.createElement(import_material.Box, { sx: { flex: 1, border: 1, borderColor: "divider", borderRadius: 1, overflowY: "auto" } }, /* @__PURE__ */ import_react.default.createElement(import_material.List, { dense: true, disablePadding: true }, configs.map((cfg) => /* @__PURE__ */ import_react.default.createElement(import_material.ListItem, { key: cfg.name, disablePadding: true, secondaryAction: /* @__PURE__ */ import_react.default.createElement(import_material.IconButton, { size: "small", color: "error", disabled: deleting === cfg.name, onClick: () => deleteConfig(cfg.name) }, deleting === cfg.name ? /* @__PURE__ */ import_react.default.createElement(import_material.CircularProgress, { size: 12 }) : /* @__PURE__ */ import_react.default.createElement(import_icons_material.Delete, { sx: { fontSize: 14 } })) }, /* @__PURE__ */ import_react.default.createElement(import_material.ListItemButton, { selected: selectedName === cfg.name, onClick: () => selectConfig(cfg), dense: true, sx: { pr: 4 } }, /* @__PURE__ */ import_react.default.createElement(
|
|
143
|
+
import_material.ListItemText,
|
|
144
|
+
{
|
|
145
|
+
primary: cfg.name,
|
|
146
|
+
secondary: `${cfg.limit} / ${cfg.interval} ${cfg.unit}`,
|
|
147
|
+
primaryTypographyProps: { variant: "body2", noWrap: true },
|
|
148
|
+
secondaryTypographyProps: { variant: "caption" }
|
|
149
|
+
}
|
|
150
|
+
)))))), /* @__PURE__ */ import_react.default.createElement(import_material.Stack, { direction: "row", spacing: 0.5 }, /* @__PURE__ */ import_react.default.createElement(import_material.Button, { size: "small", startIcon: /* @__PURE__ */ import_react.default.createElement(import_icons_material.Add, null), onClick: newConfig, sx: { flex: 1 } }, "New"), /* @__PURE__ */ import_react.default.createElement(import_material.Button, { size: "small", startIcon: /* @__PURE__ */ import_react.default.createElement(import_icons_material.ContentCopy, null), onClick: cloneConfig, disabled: !selectedName, sx: { flex: 1 } }, "Clone"))), /* @__PURE__ */ import_react.default.createElement(import_material.Divider, { orientation: "vertical", flexItem: true }), /* @__PURE__ */ import_react.default.createElement(import_material.Box, { sx: { flex: 1, display: "flex", flexDirection: "column", gap: 2, minWidth: 0 } }, /* @__PURE__ */ import_react.default.createElement(import_material.Typography, { variant: "caption", color: "text.secondary", fontWeight: "bold" }, isEditing ? `Editing: ${selectedName}` : "New config"), /* @__PURE__ */ import_react.default.createElement(
|
|
151
|
+
import_material.TextField,
|
|
152
|
+
{
|
|
153
|
+
size: "small",
|
|
154
|
+
label: "Name *",
|
|
155
|
+
value: edit.name,
|
|
156
|
+
onChange: (e) => setEdit((p) => ({ ...p, name: e.target.value })),
|
|
157
|
+
fullWidth: true
|
|
158
|
+
}
|
|
159
|
+
), /* @__PURE__ */ import_react.default.createElement(
|
|
160
|
+
import_material.TextField,
|
|
161
|
+
{
|
|
162
|
+
size: "small",
|
|
163
|
+
label: "Description",
|
|
164
|
+
value: edit.description ?? "",
|
|
165
|
+
onChange: (e) => setEdit((p) => ({ ...p, description: e.target.value })),
|
|
166
|
+
fullWidth: true
|
|
167
|
+
}
|
|
168
|
+
), /* @__PURE__ */ import_react.default.createElement(import_material.Divider, null, /* @__PURE__ */ import_react.default.createElement(import_material.Typography, { variant: "caption" }, "Rate limit")), /* @__PURE__ */ import_react.default.createElement(import_material.Stack, { direction: "row", spacing: 1 }, /* @__PURE__ */ import_react.default.createElement(
|
|
169
|
+
import_material.TextField,
|
|
170
|
+
{
|
|
171
|
+
size: "small",
|
|
172
|
+
label: "Max messages",
|
|
173
|
+
type: "number",
|
|
174
|
+
value: edit.limit,
|
|
175
|
+
onChange: (e) => setEdit((p) => ({ ...p, limit: Math.max(1, +e.target.value) })),
|
|
176
|
+
sx: { flex: 1 },
|
|
177
|
+
inputProps: { min: 1 }
|
|
178
|
+
}
|
|
179
|
+
), /* @__PURE__ */ import_react.default.createElement(
|
|
180
|
+
import_material.TextField,
|
|
181
|
+
{
|
|
182
|
+
size: "small",
|
|
183
|
+
label: "Per",
|
|
184
|
+
type: "number",
|
|
185
|
+
value: edit.interval,
|
|
186
|
+
onChange: (e) => setEdit((p) => ({ ...p, interval: Math.max(1, +e.target.value) })),
|
|
187
|
+
sx: { flex: 1 },
|
|
188
|
+
inputProps: { min: 1 }
|
|
189
|
+
}
|
|
190
|
+
), /* @__PURE__ */ import_react.default.createElement(import_material.FormControl, { size: "small", sx: { flex: 1 } }, /* @__PURE__ */ import_react.default.createElement(import_material.InputLabel, null, "Unit"), /* @__PURE__ */ import_react.default.createElement(import_material.Select, { label: "Unit", value: edit.unit, onChange: (e) => setEdit((p) => ({ ...p, unit: e.target.value })) }, Object.keys(UNIT_LABELS).map((u) => /* @__PURE__ */ import_react.default.createElement(import_material.MenuItem, { key: u, value: u }, UNIT_LABELS[u]))))), /* @__PURE__ */ import_react.default.createElement(import_material.Typography, { variant: "caption", color: "text.secondary" }, "Max ", edit.limit, " message", edit.limit !== 1 ? "s" : "", " per ", edit.interval > 1 ? edit.interval + " " : "", UNIT_LABELS[edit.unit].toLowerCase().replace(/s$/, edit.interval > 1 ? "s" : ""), ". Messages exceeding the limit are queued and delivered in the next window."), /* @__PURE__ */ import_react.default.createElement(import_material.Box, { sx: { flex: 1 } }), /* @__PURE__ */ import_react.default.createElement(import_material.Stack, { direction: "row", spacing: 1, alignItems: "center", justifyContent: "flex-end" }, error && /* @__PURE__ */ import_react.default.createElement(import_material.Typography, { variant: "caption", color: "error" }, error), /* @__PURE__ */ import_react.default.createElement(import_material.Button, { size: "small", variant: "contained", disabled: saving || !edit.name.trim(), onClick: save }, saving ? /* @__PURE__ */ import_react.default.createElement(import_material.CircularProgress, { size: 14 }) : isEditing ? "Update" : "Add")))), /* @__PURE__ */ import_react.default.createElement(import_material.DialogActions, null, /* @__PURE__ */ import_react.default.createElement(import_material.Button, { size: "small", onClick: onClose }, "Close")));
|
|
191
|
+
};
|
|
192
|
+
var RatelimitConfigDialog_default = RatelimitConfigDialog;
|
|
193
|
+
|
|
194
|
+
// src/front/index.tsx
|
|
195
|
+
window.__kwirth_senders__ = window.__kwirth_senders__ ?? {};
|
|
196
|
+
window.__kwirth_senders__["ratelimit"] = {
|
|
197
|
+
ConfigDialog: RatelimitConfigDialog_default,
|
|
198
|
+
nodeLabel: "Rate limit",
|
|
199
|
+
nodeDescription: "Limits message delivery rate. Excess messages are queued and delivered in the next time window."
|
|
200
|
+
};
|
|
201
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ratelimit",
|
|
3
|
+
"name": "@kwirthmagnify/kwirth-sender-ratelimit",
|
|
4
|
+
"displayName": "Rate Limit Sender",
|
|
5
|
+
"version": "0.1.1",
|
|
6
|
+
"description": "Rate limit filter sender for Kwirth — queues messages exceeding a configurable rate and delivers them in the next time window"
|
|
7
|
+
}
|