@donkeylabs/server 0.3.0 → 0.4.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/LICENSE +1 -1
- package/docs/api-client.md +7 -7
- package/docs/cache.md +1 -74
- package/docs/core-services.md +4 -116
- package/docs/cron.md +1 -1
- package/docs/errors.md +2 -2
- package/docs/events.md +3 -98
- package/docs/handlers.md +13 -48
- package/docs/logger.md +3 -58
- package/docs/middleware.md +2 -2
- package/docs/plugins.md +13 -64
- package/docs/project-structure.md +4 -142
- package/docs/rate-limiter.md +4 -136
- package/docs/router.md +6 -14
- package/docs/sse.md +1 -99
- package/docs/sveltekit-adapter.md +420 -0
- package/package.json +8 -11
- package/registry.d.ts +15 -14
- package/src/core/cache.ts +0 -75
- package/src/core/cron.ts +3 -96
- package/src/core/errors.ts +78 -11
- package/src/core/events.ts +1 -47
- package/src/core/index.ts +0 -4
- package/src/core/jobs.ts +0 -112
- package/src/core/logger.ts +12 -79
- package/src/core/rate-limiter.ts +29 -108
- package/src/core/sse.ts +1 -84
- package/src/core.ts +13 -104
- package/src/generator/index.ts +566 -0
- package/src/generator/zod-to-ts.ts +114 -0
- package/src/handlers.ts +14 -110
- package/src/index.ts +30 -24
- package/src/middleware.ts +2 -5
- package/src/registry.ts +4 -0
- package/src/router.ts +47 -1
- package/src/server.ts +618 -332
- package/README.md +0 -254
- package/cli/commands/dev.ts +0 -134
- package/cli/commands/generate.ts +0 -605
- package/cli/commands/init.ts +0 -205
- package/cli/commands/interactive.ts +0 -417
- package/cli/commands/plugin.ts +0 -192
- package/cli/commands/route.ts +0 -195
- package/cli/donkeylabs +0 -2
- package/cli/index.ts +0 -114
- package/docs/svelte-frontend.md +0 -324
- package/docs/testing.md +0 -438
- package/mcp/donkeylabs-mcp +0 -3238
- package/mcp/server.ts +0 -3238
package/src/core/logger.ts
CHANGED
|
@@ -19,8 +19,6 @@ export interface LoggerConfig {
|
|
|
19
19
|
level?: LogLevel;
|
|
20
20
|
transports?: LogTransport[];
|
|
21
21
|
format?: "json" | "pretty";
|
|
22
|
-
/** Timezone for timestamps (e.g., "America/New_York", "UTC"). Defaults to local timezone. */
|
|
23
|
-
timezone?: string;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
export interface Logger {
|
|
@@ -49,86 +47,27 @@ const RESET = "\x1b[0m";
|
|
|
49
47
|
|
|
50
48
|
// Console transport with pretty or JSON formatting
|
|
51
49
|
export class ConsoleTransport implements LogTransport {
|
|
52
|
-
private format: "json" | "pretty"
|
|
53
|
-
private timezone?: string;
|
|
54
|
-
|
|
55
|
-
constructor(format: "json" | "pretty" = "pretty", timezone?: string) {
|
|
56
|
-
this.format = format;
|
|
57
|
-
this.timezone = timezone;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
private formatTimestamp(date: Date): string {
|
|
61
|
-
if (this.timezone) {
|
|
62
|
-
try {
|
|
63
|
-
return date.toLocaleString("en-US", {
|
|
64
|
-
timeZone: this.timezone,
|
|
65
|
-
hour12: false,
|
|
66
|
-
hour: "2-digit",
|
|
67
|
-
minute: "2-digit",
|
|
68
|
-
second: "2-digit",
|
|
69
|
-
fractionalSecondDigits: 3,
|
|
70
|
-
} as Intl.DateTimeFormatOptions);
|
|
71
|
-
} catch {
|
|
72
|
-
// Invalid timezone, fall back to ISO
|
|
73
|
-
return date.toISOString().slice(11, 23);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return date.toISOString().slice(11, 23);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
private formatTimezone(): string {
|
|
80
|
-
if (!this.timezone) return "";
|
|
81
|
-
// Get short timezone abbreviation
|
|
82
|
-
try {
|
|
83
|
-
const tzPart = new Date().toLocaleString("en-US", {
|
|
84
|
-
timeZone: this.timezone,
|
|
85
|
-
timeZoneName: "short",
|
|
86
|
-
}).split(" ").pop();
|
|
87
|
-
return ` ${tzPart}`;
|
|
88
|
-
} catch {
|
|
89
|
-
return "";
|
|
90
|
-
}
|
|
91
|
-
}
|
|
50
|
+
constructor(private format: "json" | "pretty" = "pretty") {}
|
|
92
51
|
|
|
93
52
|
log(entry: LogEntry): void {
|
|
94
53
|
if (this.format === "json") {
|
|
95
54
|
console.log(JSON.stringify({
|
|
96
55
|
timestamp: entry.timestamp.toISOString(),
|
|
97
56
|
level: entry.level,
|
|
98
|
-
...(entry.context?.plugin ? { plugin: entry.context.plugin } : {}),
|
|
99
57
|
message: entry.message,
|
|
100
58
|
...entry.data,
|
|
101
|
-
|
|
102
|
-
...(entry.context ? Object.fromEntries(
|
|
103
|
-
Object.entries(entry.context).filter(([k]) => k !== "plugin")
|
|
104
|
-
) : {}),
|
|
59
|
+
...entry.context,
|
|
105
60
|
}));
|
|
106
61
|
} else {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const level = entry.level.toUpperCase();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
// Build prefix: [timestamp tz][plugin][LEVEL]
|
|
116
|
-
const timePart = `\x1b[90m[${time}${tz}]${RESET}`;
|
|
117
|
-
const pluginPart = plugin ? `\x1b[35m[${plugin}]${RESET}` : "";
|
|
118
|
-
const levelPart = `${levelColor}[${level}]${RESET}`;
|
|
119
|
-
|
|
120
|
-
let output = `${timePart}${pluginPart}${levelPart} ${entry.message}`;
|
|
121
|
-
|
|
122
|
-
// Show data as key=value pairs (excluding plugin from context since it's in prefix)
|
|
123
|
-
const contextWithoutPlugin = entry.context
|
|
124
|
-
? Object.fromEntries(Object.entries(entry.context).filter(([k]) => k !== "plugin"))
|
|
125
|
-
: {};
|
|
126
|
-
const extra = { ...entry.data, ...contextWithoutPlugin };
|
|
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 };
|
|
127
69
|
if (Object.keys(extra).length > 0) {
|
|
128
|
-
|
|
129
|
-
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
130
|
-
.join(" ");
|
|
131
|
-
output += ` ${"\x1b[90m"}${pairs}${RESET}`;
|
|
70
|
+
output += ` ${"\x1b[90m"}${JSON.stringify(extra)}${RESET}`;
|
|
132
71
|
}
|
|
133
72
|
|
|
134
73
|
console.log(output);
|
|
@@ -140,12 +79,10 @@ class LoggerImpl implements Logger {
|
|
|
140
79
|
private minLevel: number;
|
|
141
80
|
private transports: LogTransport[];
|
|
142
81
|
private context: Record<string, any>;
|
|
143
|
-
private config: LoggerConfig;
|
|
144
82
|
|
|
145
83
|
constructor(config: LoggerConfig = {}, context: Record<string, any> = {}) {
|
|
146
|
-
this.config = config;
|
|
147
84
|
this.minLevel = LOG_LEVELS[config.level ?? "info"];
|
|
148
|
-
this.transports = config.transports ?? [new ConsoleTransport(config.format ?? "pretty"
|
|
85
|
+
this.transports = config.transports ?? [new ConsoleTransport(config.format ?? "pretty")];
|
|
149
86
|
this.context = context;
|
|
150
87
|
}
|
|
151
88
|
|
|
@@ -183,11 +120,7 @@ class LoggerImpl implements Logger {
|
|
|
183
120
|
|
|
184
121
|
child(context: Record<string, any>): Logger {
|
|
185
122
|
return new LoggerImpl(
|
|
186
|
-
{
|
|
187
|
-
...this.config,
|
|
188
|
-
level: Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k as LogLevel] === this.minLevel) as LogLevel,
|
|
189
|
-
transports: this.transports,
|
|
190
|
-
},
|
|
123
|
+
{ level: Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k as LogLevel] === this.minLevel) as LogLevel, transports: this.transports },
|
|
191
124
|
{ ...this.context, ...context }
|
|
192
125
|
);
|
|
193
126
|
}
|
package/src/core/rate-limiter.ts
CHANGED
|
@@ -19,34 +19,9 @@ export interface RateLimiterConfig {
|
|
|
19
19
|
adapter?: RateLimitAdapter;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export interface RateLimitRule {
|
|
23
|
-
/** Max requests allowed in window */
|
|
24
|
-
limit: number;
|
|
25
|
-
/** Time window (supports "10s", "1m", "1h" or milliseconds) */
|
|
26
|
-
window: string | number;
|
|
27
|
-
/** Custom key extraction (defaults to IP + route) */
|
|
28
|
-
keyFn?: (ctx: { ip: string; route: string; user?: any }) => string;
|
|
29
|
-
/** Skip rate limiting for certain conditions */
|
|
30
|
-
skip?: (ctx: { ip: string; route: string; user?: any }) => boolean;
|
|
31
|
-
/** Custom error message when rate limited */
|
|
32
|
-
message?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
22
|
export interface RateLimiter {
|
|
36
|
-
// Manual rate limiting
|
|
37
23
|
check(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
38
24
|
reset(key: string): Promise<void>;
|
|
39
|
-
|
|
40
|
-
// Declarative rule registration
|
|
41
|
-
registerRule(pattern: string, rule: RateLimitRule): void;
|
|
42
|
-
registerRules(rules: Record<string, RateLimitRule>): void;
|
|
43
|
-
getRule(route: string): { pattern: string; rule: RateLimitRule } | undefined;
|
|
44
|
-
matchRoute(route: string): RateLimitRule | undefined;
|
|
45
|
-
/** List all registered rules */
|
|
46
|
-
listRules(): Array<{ pattern: string; limit: number; window: string | number }>;
|
|
47
|
-
|
|
48
|
-
/** Check a route against registered rules */
|
|
49
|
-
checkRoute(route: string, ctx: { ip: string; user?: any }): Promise<RateLimitResult | null>;
|
|
50
25
|
}
|
|
51
26
|
|
|
52
27
|
// In-memory rate limit adapter using sliding window
|
|
@@ -105,90 +80,11 @@ export class MemoryRateLimitAdapter implements RateLimitAdapter {
|
|
|
105
80
|
|
|
106
81
|
class RateLimiterImpl implements RateLimiter {
|
|
107
82
|
private adapter: RateLimitAdapter;
|
|
108
|
-
private rules = new Map<string, RateLimitRule>();
|
|
109
|
-
private rulePatterns: Array<{ pattern: string; regex: RegExp; rule: RateLimitRule }> = [];
|
|
110
83
|
|
|
111
84
|
constructor(config: RateLimiterConfig = {}) {
|
|
112
85
|
this.adapter = config.adapter ?? new MemoryRateLimitAdapter();
|
|
113
86
|
}
|
|
114
87
|
|
|
115
|
-
registerRule(pattern: string, rule: RateLimitRule): void {
|
|
116
|
-
this.rules.set(pattern, rule);
|
|
117
|
-
|
|
118
|
-
// Pre-compile regex for patterns with wildcards
|
|
119
|
-
if (pattern.includes("*")) {
|
|
120
|
-
const regex = new RegExp(
|
|
121
|
-
"^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
|
|
122
|
-
);
|
|
123
|
-
this.rulePatterns.push({ pattern, regex, rule });
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
registerRules(rules: Record<string, RateLimitRule>): void {
|
|
128
|
-
for (const [pattern, rule] of Object.entries(rules)) {
|
|
129
|
-
this.registerRule(pattern, rule);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
getRule(route: string): { pattern: string; rule: RateLimitRule } | undefined {
|
|
134
|
-
// Exact match first
|
|
135
|
-
const exact = this.rules.get(route);
|
|
136
|
-
if (exact && !route.includes("*")) {
|
|
137
|
-
return { pattern: route, rule: exact };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Pattern match (most specific first - by length)
|
|
141
|
-
const matches = this.rulePatterns
|
|
142
|
-
.filter(({ regex }) => regex.test(route))
|
|
143
|
-
.sort((a, b) => b.pattern.length - a.pattern.length);
|
|
144
|
-
|
|
145
|
-
const first = matches[0];
|
|
146
|
-
if (first) {
|
|
147
|
-
return { pattern: first.pattern, rule: first.rule };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return undefined;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
matchRoute(route: string): RateLimitRule | undefined {
|
|
154
|
-
return this.getRule(route)?.rule;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
listRules(): Array<{ pattern: string; limit: number; window: string | number }> {
|
|
158
|
-
return Array.from(this.rules.entries()).map(([pattern, rule]) => ({
|
|
159
|
-
pattern,
|
|
160
|
-
limit: rule.limit,
|
|
161
|
-
window: rule.window,
|
|
162
|
-
}));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async checkRoute(
|
|
166
|
-
route: string,
|
|
167
|
-
ctx: { ip: string; user?: any }
|
|
168
|
-
): Promise<RateLimitResult | null> {
|
|
169
|
-
const match = this.getRule(route);
|
|
170
|
-
if (!match) return null;
|
|
171
|
-
|
|
172
|
-
const { rule } = match;
|
|
173
|
-
|
|
174
|
-
// Check skip condition
|
|
175
|
-
if (rule.skip?.({ ip: ctx.ip, route, user: ctx.user })) {
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Generate key
|
|
180
|
-
const key = rule.keyFn
|
|
181
|
-
? rule.keyFn({ ip: ctx.ip, route, user: ctx.user })
|
|
182
|
-
: createRateLimitKey(route, ctx.ip);
|
|
183
|
-
|
|
184
|
-
// Parse window
|
|
185
|
-
const windowMs = typeof rule.window === "string"
|
|
186
|
-
? parseDuration(rule.window)
|
|
187
|
-
: rule.window;
|
|
188
|
-
|
|
189
|
-
return this.check(key, rule.limit, windowMs);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
88
|
async check(key: string, limit: number, windowMs: number): Promise<RateLimitResult> {
|
|
193
89
|
const { count, resetAt } = await this.adapter.increment(key, windowMs);
|
|
194
90
|
|
|
@@ -218,7 +114,18 @@ export function createRateLimiter(config?: RateLimiterConfig): RateLimiter {
|
|
|
218
114
|
return new RateLimiterImpl(config);
|
|
219
115
|
}
|
|
220
116
|
|
|
117
|
+
// ==========================================
|
|
221
118
|
// IP Extraction Utilities
|
|
119
|
+
// ==========================================
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Priority order for IP detection headers:
|
|
123
|
+
* 1. CF-Connecting-IP (Cloudflare)
|
|
124
|
+
* 2. True-Client-IP (Akamai, Cloudflare Enterprise)
|
|
125
|
+
* 3. X-Real-IP (Nginx)
|
|
126
|
+
* 4. X-Forwarded-For (first IP in chain)
|
|
127
|
+
* 5. Request socket address (direct connection)
|
|
128
|
+
*/
|
|
222
129
|
const IP_HEADERS = [
|
|
223
130
|
"cf-connecting-ip",
|
|
224
131
|
"true-client-ip",
|
|
@@ -226,7 +133,10 @@ const IP_HEADERS = [
|
|
|
226
133
|
"x-forwarded-for",
|
|
227
134
|
] as const;
|
|
228
135
|
|
|
229
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Extract client IP address from request headers
|
|
138
|
+
* Handles various proxy configurations (Cloudflare, Nginx, etc.)
|
|
139
|
+
*/
|
|
230
140
|
export function extractClientIP(req: Request, socketAddr?: string): string {
|
|
231
141
|
for (const header of IP_HEADERS) {
|
|
232
142
|
const value = req.headers.get(header);
|
|
@@ -249,7 +159,9 @@ export function extractClientIP(req: Request, socketAddr?: string): string {
|
|
|
249
159
|
return "unknown";
|
|
250
160
|
}
|
|
251
161
|
|
|
252
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* Basic IP address validation (IPv4 and IPv6)
|
|
164
|
+
*/
|
|
253
165
|
function isValidIP(ip: string): boolean {
|
|
254
166
|
// IPv4 pattern
|
|
255
167
|
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
@@ -271,7 +183,14 @@ function isValidIP(ip: string): boolean {
|
|
|
271
183
|
return false;
|
|
272
184
|
}
|
|
273
185
|
|
|
274
|
-
|
|
186
|
+
// ==========================================
|
|
187
|
+
// Rate Limit Helper Utilities
|
|
188
|
+
// ==========================================
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse duration string to milliseconds
|
|
192
|
+
* Supports: "100ms", "10s", "5m", "1h", "1d"
|
|
193
|
+
*/
|
|
275
194
|
export function parseDuration(duration: string): number {
|
|
276
195
|
const match = duration.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
277
196
|
if (!match || !match[1] || !match[2]) {
|
|
@@ -291,7 +210,9 @@ export function parseDuration(duration: string): number {
|
|
|
291
210
|
}
|
|
292
211
|
}
|
|
293
212
|
|
|
294
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Create a rate limit key for a specific route + IP combination
|
|
215
|
+
*/
|
|
295
216
|
export function createRateLimitKey(route: string, ip: string): string {
|
|
296
217
|
return `ratelimit:${route}:${ip}`;
|
|
297
218
|
}
|
package/src/core/sse.ts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
// Core SSE Service
|
|
2
|
-
// Server-Sent Events for server→client push
|
|
3
|
-
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
|
|
6
|
-
export interface SSEChannelConfig {
|
|
7
|
-
/** Event schemas for this channel */
|
|
8
|
-
events?: Record<string, z.ZodType<any>>;
|
|
9
|
-
/** If true, channel name is a pattern (e.g., "user:*") */
|
|
10
|
-
pattern?: boolean;
|
|
11
|
-
/** Optional description */
|
|
12
|
-
description?: string;
|
|
13
|
-
}
|
|
2
|
+
// Server-Sent Events for server→client push
|
|
14
3
|
|
|
15
4
|
export interface SSEClient {
|
|
16
5
|
id: string;
|
|
@@ -26,27 +15,14 @@ export interface SSEConfig {
|
|
|
26
15
|
}
|
|
27
16
|
|
|
28
17
|
export interface SSE {
|
|
29
|
-
// Channel registration (strict - channels must be registered)
|
|
30
|
-
registerChannel(name: string, config?: SSEChannelConfig): void;
|
|
31
|
-
registerChannels(channels: Record<string, SSEChannelConfig>): void;
|
|
32
|
-
isChannelRegistered(channel: string): boolean;
|
|
33
|
-
getChannelConfig(channel: string): SSEChannelConfig | undefined;
|
|
34
|
-
/** List all registered channel names (including patterns) */
|
|
35
|
-
listChannels(): string[];
|
|
36
|
-
|
|
37
|
-
// Client management
|
|
38
18
|
addClient(options?: { lastEventId?: string }): { client: SSEClient; response: Response };
|
|
39
19
|
removeClient(clientId: string): void;
|
|
40
20
|
getClient(clientId: string): SSEClient | undefined;
|
|
41
21
|
subscribe(clientId: string, channel: string): boolean;
|
|
42
22
|
unsubscribe(clientId: string, channel: string): boolean;
|
|
43
|
-
|
|
44
|
-
// Broadcasting (validates against registered channel schemas)
|
|
45
23
|
broadcast(channel: string, event: string, data: any, id?: string): void;
|
|
46
24
|
broadcastAll(event: string, data: any, id?: string): void;
|
|
47
25
|
sendTo(clientId: string, event: string, data: any, id?: string): boolean;
|
|
48
|
-
|
|
49
|
-
// Queries
|
|
50
26
|
getClients(): SSEClient[];
|
|
51
27
|
getClientsByChannel(channel: string): SSEClient[];
|
|
52
28
|
shutdown(): void;
|
|
@@ -54,8 +30,6 @@ export interface SSE {
|
|
|
54
30
|
|
|
55
31
|
class SSEImpl implements SSE {
|
|
56
32
|
private clients = new Map<string, SSEClient>();
|
|
57
|
-
private channels = new Map<string, SSEChannelConfig>();
|
|
58
|
-
private channelPatterns: Array<{ pattern: string; regex: RegExp; config: SSEChannelConfig }> = [];
|
|
59
33
|
private heartbeatInterval: number;
|
|
60
34
|
private retryInterval: number;
|
|
61
35
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -72,48 +46,6 @@ class SSEImpl implements SSE {
|
|
|
72
46
|
}, this.heartbeatInterval);
|
|
73
47
|
}
|
|
74
48
|
|
|
75
|
-
registerChannel(name: string, config: SSEChannelConfig = {}): void {
|
|
76
|
-
if (config.pattern || name.includes("*")) {
|
|
77
|
-
const regex = new RegExp(
|
|
78
|
-
"^" + name.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"
|
|
79
|
-
);
|
|
80
|
-
this.channelPatterns.push({ pattern: name, regex, config });
|
|
81
|
-
} else {
|
|
82
|
-
this.channels.set(name, config);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
registerChannels(channels: Record<string, SSEChannelConfig>): void {
|
|
87
|
-
for (const [name, config] of Object.entries(channels)) {
|
|
88
|
-
this.registerChannel(name, config);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
isChannelRegistered(channel: string): boolean {
|
|
93
|
-
if (this.channels.has(channel)) return true;
|
|
94
|
-
return this.channelPatterns.some(({ regex }) => regex.test(channel));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
getChannelConfig(channel: string): SSEChannelConfig | undefined {
|
|
98
|
-
// Direct match first
|
|
99
|
-
const direct = this.channels.get(channel);
|
|
100
|
-
if (direct) return direct;
|
|
101
|
-
|
|
102
|
-
// Pattern match (most specific first - by pattern length)
|
|
103
|
-
const matches = this.channelPatterns
|
|
104
|
-
.filter(({ regex }) => regex.test(channel))
|
|
105
|
-
.sort((a, b) => b.pattern.length - a.pattern.length);
|
|
106
|
-
|
|
107
|
-
return matches[0]?.config;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
listChannels(): string[] {
|
|
111
|
-
return [
|
|
112
|
-
...Array.from(this.channels.keys()),
|
|
113
|
-
...this.channelPatterns.map(p => p.pattern),
|
|
114
|
-
];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
49
|
addClient(options: { lastEventId?: string } = {}): { client: SSEClient; response: Response } {
|
|
118
50
|
const id = `sse_${++this.clientCounter}_${Date.now()}`;
|
|
119
51
|
|
|
@@ -184,21 +116,6 @@ class SSEImpl implements SSE {
|
|
|
184
116
|
}
|
|
185
117
|
|
|
186
118
|
broadcast(channel: string, event: string, data: any, id?: string): void {
|
|
187
|
-
// Strict mode: channel must be registered
|
|
188
|
-
const config = this.getChannelConfig(channel);
|
|
189
|
-
if (!config) {
|
|
190
|
-
throw new Error(`SSE channel '${channel}' is not registered. Register it first with sse.registerChannel() or in plugin sseChannels config.`);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Validate event data if schema exists
|
|
194
|
-
if (config.events?.[event]) {
|
|
195
|
-
const schema = config.events[event];
|
|
196
|
-
const result = schema.safeParse(data);
|
|
197
|
-
if (!result.success) {
|
|
198
|
-
throw new Error(`SSE event '${event}' on channel '${channel}' validation failed: ${result.error.message}`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
119
|
for (const client of this.clients.values()) {
|
|
203
120
|
if (client.channels.has(channel)) {
|
|
204
121
|
this.sendEvent(client, event, data, id);
|
package/src/core.ts
CHANGED
|
@@ -3,12 +3,11 @@ import { readdir } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { z } from "zod";
|
|
5
5
|
import type { Logger } from "./core/logger";
|
|
6
|
-
|
|
7
|
-
import type { Cache, NamespacedCache } from "./core/cache";
|
|
6
|
+
import type { Cache } from "./core/cache";
|
|
8
7
|
import type { Events } from "./core/events";
|
|
9
|
-
import type { Cron
|
|
10
|
-
import type { Jobs
|
|
11
|
-
import type { SSE
|
|
8
|
+
import type { Cron } from "./core/cron";
|
|
9
|
+
import type { Jobs } from "./core/jobs";
|
|
10
|
+
import type { SSE } from "./core/sse";
|
|
12
11
|
import type { RateLimiter } from "./core/rate-limiter";
|
|
13
12
|
import type { Errors, CustomErrorRegistry } from "./core/errors";
|
|
14
13
|
|
|
@@ -81,39 +80,15 @@ export interface GlobalContext {
|
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
export class PluginContext<Deps = any, Schema = any, Config = void> {
|
|
84
|
-
private _logger?: Logger;
|
|
85
|
-
private _cache?: NamespacedCache;
|
|
86
|
-
|
|
87
83
|
constructor(
|
|
88
84
|
public readonly core: CoreServices,
|
|
89
85
|
public readonly deps: Deps,
|
|
90
|
-
public readonly config: Config
|
|
91
|
-
public readonly pluginName?: string
|
|
86
|
+
public readonly config: Config
|
|
92
87
|
) {}
|
|
93
88
|
|
|
94
89
|
get db(): Kysely<Schema> {
|
|
95
90
|
return this.core.db as unknown as Kysely<Schema>;
|
|
96
91
|
}
|
|
97
|
-
|
|
98
|
-
/** Get a child logger with plugin context */
|
|
99
|
-
get logger(): Logger {
|
|
100
|
-
if (!this._logger) {
|
|
101
|
-
this._logger = this.pluginName
|
|
102
|
-
? this.core.logger.child({ plugin: this.pluginName })
|
|
103
|
-
: this.core.logger;
|
|
104
|
-
}
|
|
105
|
-
return this._logger;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Get a namespaced cache for this plugin */
|
|
109
|
-
get cache(): NamespacedCache {
|
|
110
|
-
if (!this._cache) {
|
|
111
|
-
this._cache = this.pluginName
|
|
112
|
-
? this.core.cache.namespace(this.pluginName)
|
|
113
|
-
: this.core.cache.namespace("default");
|
|
114
|
-
}
|
|
115
|
-
return this._cache;
|
|
116
|
-
}
|
|
117
92
|
}
|
|
118
93
|
|
|
119
94
|
type UnionToIntersection<U> =
|
|
@@ -221,12 +196,6 @@ export class PluginBuilder<LocalSchema = {}> {
|
|
|
221
196
|
service: Service
|
|
222
197
|
) => Middleware;
|
|
223
198
|
events?: Events;
|
|
224
|
-
/** SSE channel configurations with event schemas */
|
|
225
|
-
sseChannels?: Record<string, SSEChannelConfig>;
|
|
226
|
-
/** Job definitions with schemas for payload validation */
|
|
227
|
-
jobs?: Record<string, JobDefinition>;
|
|
228
|
-
/** Cron task definitions */
|
|
229
|
-
cronTasks?: Record<string, CronTaskDefinition>;
|
|
230
199
|
client?: ClientConfig;
|
|
231
200
|
customErrors?: CustomErrors;
|
|
232
201
|
service: (
|
|
@@ -250,8 +219,6 @@ export class PluginBuilder<LocalSchema = {}> {
|
|
|
250
219
|
handlers?: Handlers;
|
|
251
220
|
middleware?: (ctx: PluginContext<ExtractServices<Deps>, LocalSchema & ExtractSchemas<Deps>, void>, service: Service) => Middleware;
|
|
252
221
|
events?: Events;
|
|
253
|
-
jobs?: Record<string, JobDefinition>;
|
|
254
|
-
cronTasks?: Record<string, CronTaskDefinition>;
|
|
255
222
|
client?: ClientConfig;
|
|
256
223
|
customErrors?: CustomErrors;
|
|
257
224
|
} {
|
|
@@ -284,12 +251,6 @@ export class ConfiguredPluginBuilder<LocalSchema, Config> {
|
|
|
284
251
|
service: Service
|
|
285
252
|
) => Middleware;
|
|
286
253
|
events?: Events;
|
|
287
|
-
/** SSE channel configurations with event schemas */
|
|
288
|
-
sseChannels?: Record<string, SSEChannelConfig>;
|
|
289
|
-
/** Job definitions with schemas for payload validation */
|
|
290
|
-
jobs?: Record<string, JobDefinition>;
|
|
291
|
-
/** Cron task definitions */
|
|
292
|
-
cronTasks?: Record<string, CronTaskDefinition>;
|
|
293
254
|
client?: ClientConfig;
|
|
294
255
|
customErrors?: CustomErrors;
|
|
295
256
|
service: (
|
|
@@ -314,9 +275,6 @@ export class ConfiguredPluginBuilder<LocalSchema, Config> {
|
|
|
314
275
|
handlers?: Handlers;
|
|
315
276
|
middleware?: (ctx: PluginContext<ExtractServices<Deps>, LocalSchema & ExtractSchemas<Deps>, Config>, service: Service) => Middleware;
|
|
316
277
|
events?: Events;
|
|
317
|
-
sseChannels?: Record<string, SSEChannelConfig>;
|
|
318
|
-
jobs?: Record<string, JobDefinition>;
|
|
319
|
-
cronTasks?: Record<string, CronTaskDefinition>;
|
|
320
278
|
client?: ClientConfig;
|
|
321
279
|
customErrors?: CustomErrors;
|
|
322
280
|
}> {
|
|
@@ -346,12 +304,6 @@ export type InferCustomErrors<T> = UnwrapPluginFactory<T> extends { customErrors
|
|
|
346
304
|
|
|
347
305
|
export type { ExtractServices, ExtractSchemas };
|
|
348
306
|
|
|
349
|
-
export interface Migration {
|
|
350
|
-
name: string;
|
|
351
|
-
up: (db: Kysely<any>) => Promise<void>;
|
|
352
|
-
down?: (db: Kysely<any>) => Promise<void>;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
307
|
export type Plugin = {
|
|
356
308
|
name: string;
|
|
357
309
|
version?: string;
|
|
@@ -360,14 +312,6 @@ export type Plugin = {
|
|
|
360
312
|
/** Middleware function - receives PluginContext and service, returns middleware definitions */
|
|
361
313
|
middleware?: (ctx: any, service: any) => Record<string, any>;
|
|
362
314
|
events?: Record<string, any>;
|
|
363
|
-
/** SSE channel configurations with event schemas */
|
|
364
|
-
sseChannels?: Record<string, SSEChannelConfig>;
|
|
365
|
-
/** Job definitions with schemas for payload validation */
|
|
366
|
-
jobs?: Record<string, JobDefinition>;
|
|
367
|
-
/** Cron task definitions */
|
|
368
|
-
cronTasks?: Record<string, CronTaskDefinition>;
|
|
369
|
-
/** Inline migrations (alternative to file-based migrations) */
|
|
370
|
-
migrations?: Migration[];
|
|
371
315
|
client?: ClientConfig;
|
|
372
316
|
customErrors?: CustomErrorRegistry;
|
|
373
317
|
service: (ctx: any) => any;
|
|
@@ -410,28 +354,13 @@ export class PluginManager {
|
|
|
410
354
|
}
|
|
411
355
|
|
|
412
356
|
async migrate(): Promise<void> {
|
|
357
|
+
console.log("Running migrations (File-System Based)...");
|
|
413
358
|
const sortedPlugins = this.resolveOrder();
|
|
414
359
|
|
|
415
360
|
for (const plugin of sortedPlugins) {
|
|
416
361
|
const pluginName = plugin.name;
|
|
417
|
-
|
|
418
|
-
// 1. Check for inline migrations (migrations array in plugin definition)
|
|
419
|
-
if (plugin.migrations && Array.isArray(plugin.migrations) && plugin.migrations.length > 0) {
|
|
420
|
-
for (const migration of plugin.migrations) {
|
|
421
|
-
if (migration.up) {
|
|
422
|
-
try {
|
|
423
|
-
await migration.up(this.core.db);
|
|
424
|
-
} catch (e) {
|
|
425
|
-
console.error(`[${pluginName}] Migration '${migration.name}' failed:`, e);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
continue; // Skip file-based migrations if inline migrations exist
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// 2. Fall back to file-based migrations
|
|
433
362
|
const possibleMigrationDirs = [
|
|
434
|
-
join(process.cwd(), "examples/
|
|
363
|
+
join(process.cwd(), "examples/basic-server/src/plugins", pluginName, "migrations"),
|
|
435
364
|
join(process.cwd(), "src/plugins", pluginName, "migrations"),
|
|
436
365
|
join(process.cwd(), "plugins", pluginName, "migrations"),
|
|
437
366
|
];
|
|
@@ -454,15 +383,19 @@ export class PluginManager {
|
|
|
454
383
|
const migrationFiles = files.filter(f => f.endsWith(".ts"));
|
|
455
384
|
|
|
456
385
|
if (migrationFiles.length > 0) {
|
|
386
|
+
console.log(`[Migration] checking plugin: ${pluginName} at ${migrationDir}`);
|
|
387
|
+
|
|
457
388
|
for (const file of migrationFiles.sort()) {
|
|
389
|
+
console.log(` - Executing migration: ${file}`);
|
|
458
390
|
const migrationPath = join(migrationDir, file);
|
|
459
391
|
const migration = await import(migrationPath);
|
|
460
392
|
|
|
461
393
|
if (migration.up) {
|
|
462
394
|
try {
|
|
463
395
|
await migration.up(this.core.db);
|
|
396
|
+
console.log(` Success`);
|
|
464
397
|
} catch (e) {
|
|
465
|
-
console.error(`
|
|
398
|
+
console.error(` Failed to run ${file}:`, e);
|
|
466
399
|
}
|
|
467
400
|
}
|
|
468
401
|
}
|
|
@@ -495,30 +428,6 @@ export class PluginManager {
|
|
|
495
428
|
}
|
|
496
429
|
}
|
|
497
430
|
|
|
498
|
-
// Auto-register plugin event schemas
|
|
499
|
-
if (plugin.events) {
|
|
500
|
-
this.core.events.registerMany(plugin.events as Record<string, any>);
|
|
501
|
-
console.log(`[${plugin.name}] Registered ${Object.keys(plugin.events).length} event schemas`);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// Auto-register plugin SSE channels
|
|
505
|
-
if (plugin.sseChannels) {
|
|
506
|
-
this.core.sse.registerChannels(plugin.sseChannels);
|
|
507
|
-
console.log(`[${plugin.name}] Registered ${Object.keys(plugin.sseChannels).length} SSE channels`);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Auto-register plugin jobs (with schema validation)
|
|
511
|
-
if (plugin.jobs) {
|
|
512
|
-
this.core.jobs.registerJobs(plugin.jobs, plugin.name);
|
|
513
|
-
console.log(`[${plugin.name}] Registered ${Object.keys(plugin.jobs).length} job handlers`);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Auto-register plugin cron tasks
|
|
517
|
-
if (plugin.cronTasks) {
|
|
518
|
-
this.core.cron.registerTasks(plugin.cronTasks, plugin.name);
|
|
519
|
-
console.log(`[${plugin.name}] Registered ${Object.keys(plugin.cronTasks).length} cron tasks`);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
431
|
const pluginDeps: Record<string, unknown> = {};
|
|
523
432
|
if (plugin.dependencies) {
|
|
524
433
|
for (const depName of plugin.dependencies) {
|
|
@@ -527,7 +436,7 @@ export class PluginManager {
|
|
|
527
436
|
}
|
|
528
437
|
|
|
529
438
|
const pluginConfig = (plugin as ConfiguredPlugin)._boundConfig;
|
|
530
|
-
const ctx = new PluginContext(this.core, pluginDeps, pluginConfig
|
|
439
|
+
const ctx = new PluginContext(this.core, pluginDeps, pluginConfig);
|
|
531
440
|
const service = await plugin.service(ctx);
|
|
532
441
|
|
|
533
442
|
if (service) {
|