@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.
@@ -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
+ }