@donkeylabs/server 0.1.0
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/README.md +15 -0
- package/cli/commands/generate.ts +461 -0
- package/cli/commands/init.ts +287 -0
- package/cli/commands/interactive.ts +223 -0
- package/cli/commands/plugin.ts +192 -0
- package/cli/donkeylabs +100 -0
- package/cli/index.ts +100 -0
- package/mcp/donkeylabs-mcp +3238 -0
- package/mcp/server.ts +3238 -0
- package/package.json +74 -0
- package/src/client/base.ts +481 -0
- package/src/client/index.ts +150 -0
- package/src/core/cache.ts +183 -0
- package/src/core/cron.ts +255 -0
- package/src/core/errors.ts +320 -0
- package/src/core/events.ts +163 -0
- package/src/core/index.ts +94 -0
- package/src/core/jobs.ts +334 -0
- package/src/core/logger.ts +131 -0
- package/src/core/rate-limiter.ts +193 -0
- package/src/core/sse.ts +210 -0
- package/src/core.ts +428 -0
- package/src/handlers.ts +87 -0
- package/src/harness.ts +70 -0
- package/src/index.ts +38 -0
- package/src/middleware.ts +34 -0
- package/src/registry.ts +13 -0
- package/src/router.ts +155 -0
- package/src/server.ts +233 -0
- package/templates/init/donkeylabs.config.ts.template +14 -0
- package/templates/init/index.ts.template +41 -0
- package/templates/plugin/index.ts.template +25 -0
package/src/core/jobs.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Core Jobs Service
|
|
2
|
+
// Background job queue with scheduling
|
|
3
|
+
|
|
4
|
+
import type { Events } from "./events";
|
|
5
|
+
|
|
6
|
+
export type JobStatus = "pending" | "running" | "completed" | "failed" | "scheduled";
|
|
7
|
+
|
|
8
|
+
export interface Job {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
data: any;
|
|
12
|
+
status: JobStatus;
|
|
13
|
+
createdAt: Date;
|
|
14
|
+
runAt?: Date;
|
|
15
|
+
startedAt?: Date;
|
|
16
|
+
completedAt?: Date;
|
|
17
|
+
result?: any;
|
|
18
|
+
error?: string;
|
|
19
|
+
attempts: number;
|
|
20
|
+
maxAttempts: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface JobHandler<T = any, R = any> {
|
|
24
|
+
(data: T): Promise<R>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JobAdapter {
|
|
28
|
+
create(job: Omit<Job, "id">): Promise<Job>;
|
|
29
|
+
get(jobId: string): Promise<Job | null>;
|
|
30
|
+
update(jobId: string, updates: Partial<Job>): Promise<void>;
|
|
31
|
+
delete(jobId: string): Promise<boolean>;
|
|
32
|
+
getPending(limit?: number): Promise<Job[]>;
|
|
33
|
+
getScheduledReady(now: Date): Promise<Job[]>;
|
|
34
|
+
getByName(name: string, status?: JobStatus): Promise<Job[]>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JobsConfig {
|
|
38
|
+
adapter?: JobAdapter;
|
|
39
|
+
events?: Events;
|
|
40
|
+
concurrency?: number; // Max concurrent jobs, default 5
|
|
41
|
+
pollInterval?: number; // ms, default 1000
|
|
42
|
+
maxAttempts?: number; // Default retry attempts, default 3
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Jobs {
|
|
46
|
+
register<T = any, R = any>(name: string, handler: JobHandler<T, R>): void;
|
|
47
|
+
enqueue<T = any>(name: string, data: T, options?: { maxAttempts?: number }): Promise<string>;
|
|
48
|
+
schedule<T = any>(name: string, data: T, runAt: Date, options?: { maxAttempts?: number }): Promise<string>;
|
|
49
|
+
get(jobId: string): Promise<Job | null>;
|
|
50
|
+
cancel(jobId: string): Promise<boolean>;
|
|
51
|
+
getByName(name: string, status?: JobStatus): Promise<Job[]>;
|
|
52
|
+
start(): void;
|
|
53
|
+
stop(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// In-memory job adapter
|
|
57
|
+
export class MemoryJobAdapter implements JobAdapter {
|
|
58
|
+
private jobs = new Map<string, Job>();
|
|
59
|
+
private counter = 0;
|
|
60
|
+
|
|
61
|
+
async create(job: Omit<Job, "id">): Promise<Job> {
|
|
62
|
+
const id = `job_${++this.counter}_${Date.now()}`;
|
|
63
|
+
const fullJob: Job = { ...job, id };
|
|
64
|
+
this.jobs.set(id, fullJob);
|
|
65
|
+
return fullJob;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async get(jobId: string): Promise<Job | null> {
|
|
69
|
+
return this.jobs.get(jobId) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async update(jobId: string, updates: Partial<Job>): Promise<void> {
|
|
73
|
+
const job = this.jobs.get(jobId);
|
|
74
|
+
if (job) {
|
|
75
|
+
Object.assign(job, updates);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async delete(jobId: string): Promise<boolean> {
|
|
80
|
+
return this.jobs.delete(jobId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getPending(limit: number = 100): Promise<Job[]> {
|
|
84
|
+
const pending: Job[] = [];
|
|
85
|
+
for (const job of this.jobs.values()) {
|
|
86
|
+
if (job.status === "pending") {
|
|
87
|
+
pending.push(job);
|
|
88
|
+
if (pending.length >= limit) break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return pending;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getScheduledReady(now: Date): Promise<Job[]> {
|
|
95
|
+
const ready: Job[] = [];
|
|
96
|
+
for (const job of this.jobs.values()) {
|
|
97
|
+
if (job.status === "scheduled" && job.runAt && job.runAt <= now) {
|
|
98
|
+
ready.push(job);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return ready;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getByName(name: string, status?: JobStatus): Promise<Job[]> {
|
|
105
|
+
const results: Job[] = [];
|
|
106
|
+
for (const job of this.jobs.values()) {
|
|
107
|
+
if (job.name === name && (!status || job.status === status)) {
|
|
108
|
+
results.push(job);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class JobsImpl implements Jobs {
|
|
116
|
+
private adapter: JobAdapter;
|
|
117
|
+
private events?: Events;
|
|
118
|
+
private handlers = new Map<string, JobHandler>();
|
|
119
|
+
private running = false;
|
|
120
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
121
|
+
private activeJobs = 0;
|
|
122
|
+
private concurrency: number;
|
|
123
|
+
private pollInterval: number;
|
|
124
|
+
private defaultMaxAttempts: number;
|
|
125
|
+
|
|
126
|
+
constructor(config: JobsConfig = {}) {
|
|
127
|
+
this.adapter = config.adapter ?? new MemoryJobAdapter();
|
|
128
|
+
this.events = config.events;
|
|
129
|
+
this.concurrency = config.concurrency ?? 5;
|
|
130
|
+
this.pollInterval = config.pollInterval ?? 1000;
|
|
131
|
+
this.defaultMaxAttempts = config.maxAttempts ?? 3;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
register<T = any, R = any>(name: string, handler: JobHandler<T, R>): void {
|
|
135
|
+
if (this.handlers.has(name)) {
|
|
136
|
+
throw new Error(`Job handler "${name}" is already registered`);
|
|
137
|
+
}
|
|
138
|
+
this.handlers.set(name, handler);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async enqueue<T = any>(name: string, data: T, options: { maxAttempts?: number } = {}): Promise<string> {
|
|
142
|
+
if (!this.handlers.has(name)) {
|
|
143
|
+
throw new Error(`No handler registered for job "${name}"`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const job = await this.adapter.create({
|
|
147
|
+
name,
|
|
148
|
+
data,
|
|
149
|
+
status: "pending",
|
|
150
|
+
createdAt: new Date(),
|
|
151
|
+
attempts: 0,
|
|
152
|
+
maxAttempts: options.maxAttempts ?? this.defaultMaxAttempts,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return job.id;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async schedule<T = any>(
|
|
159
|
+
name: string,
|
|
160
|
+
data: T,
|
|
161
|
+
runAt: Date,
|
|
162
|
+
options: { maxAttempts?: number } = {}
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
if (!this.handlers.has(name)) {
|
|
165
|
+
throw new Error(`No handler registered for job "${name}"`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const job = await this.adapter.create({
|
|
169
|
+
name,
|
|
170
|
+
data,
|
|
171
|
+
status: "scheduled",
|
|
172
|
+
createdAt: new Date(),
|
|
173
|
+
runAt,
|
|
174
|
+
attempts: 0,
|
|
175
|
+
maxAttempts: options.maxAttempts ?? this.defaultMaxAttempts,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return job.id;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async get(jobId: string): Promise<Job | null> {
|
|
182
|
+
return this.adapter.get(jobId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async cancel(jobId: string): Promise<boolean> {
|
|
186
|
+
const job = await this.adapter.get(jobId);
|
|
187
|
+
if (!job) return false;
|
|
188
|
+
|
|
189
|
+
if (job.status === "running") {
|
|
190
|
+
// Can't cancel running job
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return this.adapter.delete(jobId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async getByName(name: string, status?: JobStatus): Promise<Job[]> {
|
|
198
|
+
return this.adapter.getByName(name, status);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
start(): void {
|
|
202
|
+
if (this.running) return;
|
|
203
|
+
this.running = true;
|
|
204
|
+
|
|
205
|
+
this.timer = setInterval(() => this.tick(), this.pollInterval);
|
|
206
|
+
// Run immediately too
|
|
207
|
+
this.tick();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async stop(): Promise<void> {
|
|
211
|
+
this.running = false;
|
|
212
|
+
if (this.timer) {
|
|
213
|
+
clearInterval(this.timer);
|
|
214
|
+
this.timer = null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Wait for active jobs to complete (with timeout)
|
|
218
|
+
const maxWait = 30000; // 30 seconds
|
|
219
|
+
const start = Date.now();
|
|
220
|
+
while (this.activeJobs > 0 && Date.now() - start < maxWait) {
|
|
221
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async tick(): Promise<void> {
|
|
226
|
+
if (!this.running) return;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// Process scheduled jobs that are ready
|
|
230
|
+
const now = new Date();
|
|
231
|
+
const scheduledReady = await this.adapter.getScheduledReady(now);
|
|
232
|
+
for (const job of scheduledReady) {
|
|
233
|
+
await this.adapter.update(job.id, { status: "pending" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Process pending jobs
|
|
237
|
+
const availableSlots = this.concurrency - this.activeJobs;
|
|
238
|
+
if (availableSlots <= 0) return;
|
|
239
|
+
|
|
240
|
+
const pending = await this.adapter.getPending(availableSlots);
|
|
241
|
+
for (const job of pending) {
|
|
242
|
+
if (this.activeJobs >= this.concurrency) break;
|
|
243
|
+
this.processJob(job);
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error("[Jobs] Tick error:", err);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async processJob(job: Job): Promise<void> {
|
|
251
|
+
const handler = this.handlers.get(job.name);
|
|
252
|
+
if (!handler) {
|
|
253
|
+
await this.adapter.update(job.id, {
|
|
254
|
+
status: "failed",
|
|
255
|
+
error: `No handler registered for job "${job.name}"`,
|
|
256
|
+
completedAt: new Date(),
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.activeJobs++;
|
|
262
|
+
const startedAt = new Date();
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await this.adapter.update(job.id, {
|
|
266
|
+
status: "running",
|
|
267
|
+
startedAt,
|
|
268
|
+
attempts: job.attempts + 1,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const result = await handler(job.data);
|
|
272
|
+
|
|
273
|
+
await this.adapter.update(job.id, {
|
|
274
|
+
status: "completed",
|
|
275
|
+
completedAt: new Date(),
|
|
276
|
+
result,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Emit completion event
|
|
280
|
+
if (this.events) {
|
|
281
|
+
await this.events.emit(`job.completed`, {
|
|
282
|
+
jobId: job.id,
|
|
283
|
+
name: job.name,
|
|
284
|
+
result,
|
|
285
|
+
});
|
|
286
|
+
await this.events.emit(`job.${job.name}.completed`, {
|
|
287
|
+
jobId: job.id,
|
|
288
|
+
result,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
293
|
+
const attempts = job.attempts + 1;
|
|
294
|
+
|
|
295
|
+
if (attempts < job.maxAttempts) {
|
|
296
|
+
// Retry later
|
|
297
|
+
await this.adapter.update(job.id, {
|
|
298
|
+
status: "pending",
|
|
299
|
+
attempts,
|
|
300
|
+
error,
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
// Max attempts reached, mark as failed
|
|
304
|
+
await this.adapter.update(job.id, {
|
|
305
|
+
status: "failed",
|
|
306
|
+
completedAt: new Date(),
|
|
307
|
+
attempts,
|
|
308
|
+
error,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Emit failure event
|
|
312
|
+
if (this.events) {
|
|
313
|
+
await this.events.emit(`job.failed`, {
|
|
314
|
+
jobId: job.id,
|
|
315
|
+
name: job.name,
|
|
316
|
+
error,
|
|
317
|
+
attempts,
|
|
318
|
+
});
|
|
319
|
+
await this.events.emit(`job.${job.name}.failed`, {
|
|
320
|
+
jobId: job.id,
|
|
321
|
+
error,
|
|
322
|
+
attempts,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
this.activeJobs--;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function createJobs(config?: JobsConfig): Jobs {
|
|
333
|
+
return new JobsImpl(config);
|
|
334
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Core Logger Service
|
|
2
|
+
// Structured logging with levels and child loggers
|
|
3
|
+
|
|
4
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
export interface LogEntry {
|
|
7
|
+
timestamp: Date;
|
|
8
|
+
level: LogLevel;
|
|
9
|
+
message: string;
|
|
10
|
+
data?: Record<string, any>;
|
|
11
|
+
context?: Record<string, any>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LogTransport {
|
|
15
|
+
log(entry: LogEntry): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LoggerConfig {
|
|
19
|
+
level?: LogLevel;
|
|
20
|
+
transports?: LogTransport[];
|
|
21
|
+
format?: "json" | "pretty";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Logger {
|
|
25
|
+
debug(message: string, data?: Record<string, any>): void;
|
|
26
|
+
info(message: string, data?: Record<string, any>): void;
|
|
27
|
+
warn(message: string, data?: Record<string, any>): void;
|
|
28
|
+
error(message: string, data?: Record<string, any>): void;
|
|
29
|
+
child(context: Record<string, any>): Logger;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
33
|
+
debug: 0,
|
|
34
|
+
info: 1,
|
|
35
|
+
warn: 2,
|
|
36
|
+
error: 3,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const LEVEL_COLORS: Record<LogLevel, string> = {
|
|
40
|
+
debug: "\x1b[90m", // gray
|
|
41
|
+
info: "\x1b[36m", // cyan
|
|
42
|
+
warn: "\x1b[33m", // yellow
|
|
43
|
+
error: "\x1b[31m", // red
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const RESET = "\x1b[0m";
|
|
47
|
+
|
|
48
|
+
// Console transport with pretty or JSON formatting
|
|
49
|
+
export class ConsoleTransport implements LogTransport {
|
|
50
|
+
constructor(private format: "json" | "pretty" = "pretty") {}
|
|
51
|
+
|
|
52
|
+
log(entry: LogEntry): void {
|
|
53
|
+
if (this.format === "json") {
|
|
54
|
+
console.log(JSON.stringify({
|
|
55
|
+
timestamp: entry.timestamp.toISOString(),
|
|
56
|
+
level: entry.level,
|
|
57
|
+
message: entry.message,
|
|
58
|
+
...entry.data,
|
|
59
|
+
...entry.context,
|
|
60
|
+
}));
|
|
61
|
+
} else {
|
|
62
|
+
const color = LEVEL_COLORS[entry.level];
|
|
63
|
+
const time = entry.timestamp.toISOString().slice(11, 23);
|
|
64
|
+
const level = entry.level.toUpperCase().padEnd(5);
|
|
65
|
+
|
|
66
|
+
let output = `${color}[${time}] ${level}${RESET} ${entry.message}`;
|
|
67
|
+
|
|
68
|
+
const extra = { ...entry.data, ...entry.context };
|
|
69
|
+
if (Object.keys(extra).length > 0) {
|
|
70
|
+
output += ` ${"\x1b[90m"}${JSON.stringify(extra)}${RESET}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(output);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class LoggerImpl implements Logger {
|
|
79
|
+
private minLevel: number;
|
|
80
|
+
private transports: LogTransport[];
|
|
81
|
+
private context: Record<string, any>;
|
|
82
|
+
|
|
83
|
+
constructor(config: LoggerConfig = {}, context: Record<string, any> = {}) {
|
|
84
|
+
this.minLevel = LOG_LEVELS[config.level ?? "info"];
|
|
85
|
+
this.transports = config.transports ?? [new ConsoleTransport(config.format ?? "pretty")];
|
|
86
|
+
this.context = context;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private log(level: LogLevel, message: string, data?: Record<string, any>): void {
|
|
90
|
+
if (LOG_LEVELS[level] < this.minLevel) return;
|
|
91
|
+
|
|
92
|
+
const entry: LogEntry = {
|
|
93
|
+
timestamp: new Date(),
|
|
94
|
+
level,
|
|
95
|
+
message,
|
|
96
|
+
data,
|
|
97
|
+
context: Object.keys(this.context).length > 0 ? this.context : undefined,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const transport of this.transports) {
|
|
101
|
+
transport.log(entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
debug(message: string, data?: Record<string, any>): void {
|
|
106
|
+
this.log("debug", message, data);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
info(message: string, data?: Record<string, any>): void {
|
|
110
|
+
this.log("info", message, data);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
warn(message: string, data?: Record<string, any>): void {
|
|
114
|
+
this.log("warn", message, data);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
error(message: string, data?: Record<string, any>): void {
|
|
118
|
+
this.log("error", message, data);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
child(context: Record<string, any>): Logger {
|
|
122
|
+
return new LoggerImpl(
|
|
123
|
+
{ level: Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k as LogLevel] === this.minLevel) as LogLevel, transports: this.transports },
|
|
124
|
+
{ ...this.context, ...context }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createLogger(config?: LoggerConfig): Logger {
|
|
130
|
+
return new LoggerImpl(config);
|
|
131
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Core Rate Limiter Service
|
|
2
|
+
// Request throttling with IP detection
|
|
3
|
+
|
|
4
|
+
export interface RateLimitResult {
|
|
5
|
+
allowed: boolean;
|
|
6
|
+
remaining: number;
|
|
7
|
+
limit: number;
|
|
8
|
+
resetAt: Date;
|
|
9
|
+
retryAfter?: number; // seconds until retry
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RateLimitAdapter {
|
|
13
|
+
increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>;
|
|
14
|
+
get(key: string): Promise<{ count: number; resetAt: Date } | null>;
|
|
15
|
+
reset(key: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RateLimiterConfig {
|
|
19
|
+
adapter?: RateLimitAdapter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RateLimiter {
|
|
23
|
+
check(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
24
|
+
reset(key: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// In-memory rate limit adapter using sliding window
|
|
28
|
+
export class MemoryRateLimitAdapter implements RateLimitAdapter {
|
|
29
|
+
private windows = new Map<string, { count: number; resetAt: Date }>();
|
|
30
|
+
|
|
31
|
+
async increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }> {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const existing = this.windows.get(key);
|
|
34
|
+
|
|
35
|
+
if (existing && existing.resetAt.getTime() > now) {
|
|
36
|
+
// Window still active
|
|
37
|
+
existing.count++;
|
|
38
|
+
return { count: existing.count, resetAt: existing.resetAt };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Create new window
|
|
42
|
+
const resetAt = new Date(now + windowMs);
|
|
43
|
+
const entry = { count: 1, resetAt };
|
|
44
|
+
this.windows.set(key, entry);
|
|
45
|
+
|
|
46
|
+
// Clean up old entries periodically
|
|
47
|
+
this.cleanup();
|
|
48
|
+
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async get(key: string): Promise<{ count: number; resetAt: Date } | null> {
|
|
53
|
+
const entry = this.windows.get(key);
|
|
54
|
+
if (!entry) return null;
|
|
55
|
+
|
|
56
|
+
if (entry.resetAt.getTime() <= Date.now()) {
|
|
57
|
+
this.windows.delete(key);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async reset(key: string): Promise<void> {
|
|
65
|
+
this.windows.delete(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private cleanup(): void {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
// Only cleanup occasionally to avoid performance issues
|
|
71
|
+
if (Math.random() > 0.1) return;
|
|
72
|
+
|
|
73
|
+
for (const [key, entry] of this.windows.entries()) {
|
|
74
|
+
if (entry.resetAt.getTime() <= now) {
|
|
75
|
+
this.windows.delete(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class RateLimiterImpl implements RateLimiter {
|
|
82
|
+
private adapter: RateLimitAdapter;
|
|
83
|
+
|
|
84
|
+
constructor(config: RateLimiterConfig = {}) {
|
|
85
|
+
this.adapter = config.adapter ?? new MemoryRateLimitAdapter();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async check(key: string, limit: number, windowMs: number): Promise<RateLimitResult> {
|
|
89
|
+
const { count, resetAt } = await this.adapter.increment(key, windowMs);
|
|
90
|
+
|
|
91
|
+
const allowed = count <= limit;
|
|
92
|
+
const remaining = Math.max(0, limit - count);
|
|
93
|
+
|
|
94
|
+
const result: RateLimitResult = {
|
|
95
|
+
allowed,
|
|
96
|
+
remaining,
|
|
97
|
+
limit,
|
|
98
|
+
resetAt,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (!allowed) {
|
|
102
|
+
result.retryAfter = Math.ceil((resetAt.getTime() - Date.now()) / 1000);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async reset(key: string): Promise<void> {
|
|
109
|
+
await this.adapter.reset(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createRateLimiter(config?: RateLimiterConfig): RateLimiter {
|
|
114
|
+
return new RateLimiterImpl(config);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// IP Extraction Utilities
|
|
118
|
+
const IP_HEADERS = [
|
|
119
|
+
"cf-connecting-ip",
|
|
120
|
+
"true-client-ip",
|
|
121
|
+
"x-real-ip",
|
|
122
|
+
"x-forwarded-for",
|
|
123
|
+
] as const;
|
|
124
|
+
|
|
125
|
+
/** Extract client IP address from request headers (handles Cloudflare, Nginx, etc.) */
|
|
126
|
+
export function extractClientIP(req: Request, socketAddr?: string): string {
|
|
127
|
+
for (const header of IP_HEADERS) {
|
|
128
|
+
const value = req.headers.get(header);
|
|
129
|
+
if (value) {
|
|
130
|
+
// X-Forwarded-For may contain multiple IPs: "client, proxy1, proxy2"
|
|
131
|
+
if (header === "x-forwarded-for") {
|
|
132
|
+
const firstIP = value.split(",")[0]?.trim();
|
|
133
|
+
if (firstIP && isValidIP(firstIP)) return firstIP;
|
|
134
|
+
} else {
|
|
135
|
+
if (isValidIP(value)) return value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fall back to socket address
|
|
141
|
+
if (socketAddr && isValidIP(socketAddr)) {
|
|
142
|
+
return socketAddr;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return "unknown";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Basic IP address validation (IPv4 and IPv6) */
|
|
149
|
+
function isValidIP(ip: string): boolean {
|
|
150
|
+
// IPv4 pattern
|
|
151
|
+
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
152
|
+
if (ipv4Pattern.test(ip)) {
|
|
153
|
+
const parts = ip.split(".").map(Number);
|
|
154
|
+
return parts.every(n => n >= 0 && n <= 255);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// IPv6 pattern (simplified)
|
|
158
|
+
const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
159
|
+
if (ipv6Pattern.test(ip)) return true;
|
|
160
|
+
|
|
161
|
+
// IPv4-mapped IPv6
|
|
162
|
+
if (ip.startsWith("::ffff:")) {
|
|
163
|
+
const ipv4Part = ip.slice(7);
|
|
164
|
+
return isValidIP(ipv4Part);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Parse duration string to milliseconds (supports: "100ms", "10s", "5m", "1h", "1d") */
|
|
171
|
+
export function parseDuration(duration: string): number {
|
|
172
|
+
const match = duration.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
173
|
+
if (!match || !match[1] || !match[2]) {
|
|
174
|
+
throw new Error(`Invalid duration format: ${duration}. Use format like "10s", "5m", "1h"`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const value = parseInt(match[1], 10);
|
|
178
|
+
const unit = match[2];
|
|
179
|
+
|
|
180
|
+
switch (unit) {
|
|
181
|
+
case "ms": return value;
|
|
182
|
+
case "s": return value * 1000;
|
|
183
|
+
case "m": return value * 60 * 1000;
|
|
184
|
+
case "h": return value * 60 * 60 * 1000;
|
|
185
|
+
case "d": return value * 24 * 60 * 60 * 1000;
|
|
186
|
+
default: throw new Error(`Unknown duration unit: ${unit}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Create a rate limit key for a specific route + IP combination */
|
|
191
|
+
export function createRateLimitKey(route: string, ip: string): string {
|
|
192
|
+
return `ratelimit:${route}:${ip}`;
|
|
193
|
+
}
|