@iskra-bun/web-kit 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +31 -0
  3. package/dist/chunk-POXNRNTC.js +51 -0
  4. package/dist/chunk-POXNRNTC.js.map +1 -0
  5. package/dist/index.d.ts +966 -0
  6. package/dist/index.js +2824 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/mailgun-Z46GZJNI.js +83 -0
  9. package/dist/mailgun-Z46GZJNI.js.map +1 -0
  10. package/dist/s3-7IG4ESFW.js +171 -0
  11. package/dist/s3-7IG4ESFW.js.map +1 -0
  12. package/dist/sendgrid-UK2GSBEF.js +43 -0
  13. package/dist/sendgrid-UK2GSBEF.js.map +1 -0
  14. package/dist/smtp-WJDLYKD5.js +50 -0
  15. package/dist/smtp-WJDLYKD5.js.map +1 -0
  16. package/package.json +74 -0
  17. package/src/driver.ts +55 -0
  18. package/src/errors.ts +66 -0
  19. package/src/features/api-key.ts +243 -0
  20. package/src/features/auth/better-auth-config.ts +160 -0
  21. package/src/features/auth/index.ts +229 -0
  22. package/src/features/auth/schema.ts +174 -0
  23. package/src/features/auth/types.ts +114 -0
  24. package/src/features/cache.ts +144 -0
  25. package/src/features/cors.ts +33 -0
  26. package/src/features/csrf.ts +94 -0
  27. package/src/features/db.ts +90 -0
  28. package/src/features/email/index.ts +103 -0
  29. package/src/features/email/providers/mailgun.ts +99 -0
  30. package/src/features/email/providers/sendgrid.ts +42 -0
  31. package/src/features/email/providers/smtp.ts +51 -0
  32. package/src/features/error-handler.ts +147 -0
  33. package/src/features/health.ts +94 -0
  34. package/src/features/json-schema-validation.ts +186 -0
  35. package/src/features/logger.ts +70 -0
  36. package/src/features/openapi.ts +107 -0
  37. package/src/features/permissions.ts +128 -0
  38. package/src/features/rate-limit.ts +173 -0
  39. package/src/features/request-id.ts +45 -0
  40. package/src/features/session.ts +322 -0
  41. package/src/features/storage/adapters/local.ts +133 -0
  42. package/src/features/storage/adapters/s3.ts +193 -0
  43. package/src/features/storage/base.ts +112 -0
  44. package/src/features/storage/index.ts +53 -0
  45. package/src/features/tracing.ts +49 -0
  46. package/src/features/upload/helper.ts +85 -0
  47. package/src/features/upload/index.ts +140 -0
  48. package/src/features/validation.ts +105 -0
  49. package/src/index.ts +29 -0
  50. package/src/kernel.ts +257 -0
  51. package/src/responses.ts +37 -0
  52. package/src/router.ts +31 -0
  53. package/src/server.ts +135 -0
  54. package/src/types.ts +272 -0
@@ -0,0 +1,186 @@
1
+ import type { Feature } from "../types";
2
+ import type { Kernel } from "../kernel";
3
+ import type { Context, Next, Hono, Handler } from "hono";
4
+ import Ajv from "ajv";
5
+ import addErrors from "ajv-errors";
6
+ import addFormats from "ajv-formats";
7
+ import type { ErrorObject } from "ajv";
8
+ import { ErrorCodes, errorResponse } from "../responses";
9
+
10
+ // ─── Module Augmentation ────────────────────────────────────────────────────
11
+
12
+ declare module "hono" {
13
+ interface Hono {
14
+ getJsonValidated(path: string, schema: JsonValidationSchema, handler: Handler, options?: JsonValidationOptions): Hono;
15
+ postJsonValidated(path: string, schema: JsonValidationSchema, handler: Handler, options?: JsonValidationOptions): Hono;
16
+ putJsonValidated(path: string, schema: JsonValidationSchema, handler: Handler, options?: JsonValidationOptions): Hono;
17
+ deleteJsonValidated(path: string, schema: JsonValidationSchema, handler: Handler, options?: JsonValidationOptions): Hono;
18
+ }
19
+ }
20
+
21
+ // ─── Types ──────────────────────────────────────────────────────────────────
22
+
23
+ export interface JsonValidationSchema {
24
+ params?: Record<string, unknown>;
25
+ query?: Record<string, unknown>;
26
+ body?: Record<string, unknown>;
27
+ }
28
+
29
+ export interface JsonValidationOptions {
30
+ logErrors?: boolean;
31
+ status?: number;
32
+ allErrors?: boolean;
33
+ coerceTypes?: boolean;
34
+ }
35
+
36
+ export interface FormattedValidationErrors {
37
+ fields: Record<string, string[]>;
38
+ errors: string[];
39
+ }
40
+
41
+ // ─── Ajv Instance Factory ───────────────────────────────────────────────────
42
+
43
+ function createAjvInstance(options: JsonValidationOptions = {}): Ajv {
44
+ const ajv = new Ajv({
45
+ allErrors: options.allErrors ?? true,
46
+ coerceTypes: options.coerceTypes ?? true,
47
+ verbose: true,
48
+ $data: true,
49
+ });
50
+ addFormats(ajv);
51
+ addErrors(ajv);
52
+ return ajv;
53
+ }
54
+
55
+ // ─── Error Formatter ────────────────────────────────────────────────────────
56
+
57
+ function formatAjvErrors(errors: ErrorObject[] | null | undefined): FormattedValidationErrors {
58
+ const result: FormattedValidationErrors = { fields: {}, errors: [] };
59
+ if (!errors) return result;
60
+
61
+ for (const err of errors) {
62
+ let fieldPath: string;
63
+
64
+ if (err.instancePath) {
65
+ fieldPath = err.instancePath.replace(/^\//, "").replace(/\//g, ".");
66
+ } else if (err.params && "missingProperty" in err.params) {
67
+ fieldPath = err.params.missingProperty as string;
68
+ } else {
69
+ fieldPath = "_root";
70
+ }
71
+
72
+ const message = err.message || "Invalid value";
73
+
74
+ if (!result.fields[fieldPath]) {
75
+ result.fields[fieldPath] = [];
76
+ }
77
+
78
+ if (!result.fields[fieldPath].includes(message)) {
79
+ result.fields[fieldPath].push(message);
80
+ }
81
+
82
+ const formatted = fieldPath === "_root" ? message : `${fieldPath}: ${message}`;
83
+ if (!result.errors.includes(formatted)) {
84
+ result.errors.push(formatted);
85
+ }
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ // ─── Validation Middleware ───────────────────────────────────────────────────
92
+
93
+ export function createJsonSchemaValidationMiddleware(
94
+ schema: JsonValidationSchema,
95
+ options: JsonValidationOptions = {},
96
+ ) {
97
+ const { logErrors = true, status = 400 } = options;
98
+ const ajv = createAjvInstance(options);
99
+
100
+ const validators = {
101
+ params: schema.params ? ajv.compile(schema.params) : null,
102
+ query: schema.query ? ajv.compile(schema.query) : null,
103
+ body: schema.body ? ajv.compile(schema.body) : null,
104
+ };
105
+
106
+ return async (c: Context, next: Next) => {
107
+ try {
108
+ const validated: Record<string, any> = {};
109
+
110
+ if (validators.params) {
111
+ const data = { ...c.req.param() };
112
+ const valid = validators.params(data);
113
+ if (!valid) {
114
+ const details = formatAjvErrors(validators.params.errors);
115
+ return c.json(errorResponse("Invalid route params", ErrorCodes.VALIDATION_ERROR, details), status as any);
116
+ }
117
+ validated.params = data;
118
+ }
119
+
120
+ if (validators.query) {
121
+ const data = { ...c.req.query() };
122
+ const valid = validators.query(data);
123
+ if (!valid) {
124
+ const details = formatAjvErrors(validators.query.errors);
125
+ return c.json(errorResponse("Invalid query params", ErrorCodes.VALIDATION_ERROR, details), status as any);
126
+ }
127
+ validated.query = data;
128
+ }
129
+
130
+ if (validators.body) {
131
+ let data: unknown = {};
132
+ const contentType = c.req.header("content-type") || "";
133
+ if (contentType.includes("application/json")) {
134
+ data = await c.req.json().catch(() => ({}));
135
+ } else if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
136
+ data = await c.req.parseBody();
137
+ }
138
+
139
+ const valid = validators.body(data);
140
+ if (!valid) {
141
+ const details = formatAjvErrors(validators.body.errors);
142
+ return c.json(errorResponse("Invalid body", ErrorCodes.VALIDATION_ERROR, details), status as any);
143
+ }
144
+ validated.body = data;
145
+ }
146
+
147
+ (c as any).valid = () => validated;
148
+ await next();
149
+ } catch (err) {
150
+ if (logErrors) console.error("JSON Schema validation error:", err);
151
+ return c.json(errorResponse("Validation middleware failed", ErrorCodes.INTERNAL_ERROR), 500);
152
+ }
153
+ };
154
+ }
155
+
156
+ // ─── Hono Extension ─────────────────────────────────────────────────────────
157
+
158
+ function extendHonoWithJsonSchemaValidation(app: Hono) {
159
+ const methods = ["get", "post", "put", "delete"] as const;
160
+ for (const method of methods) {
161
+ (app as any)[`${method}JsonValidated`] = function (
162
+ path: string,
163
+ schema: JsonValidationSchema,
164
+ handler: Handler,
165
+ options?: JsonValidationOptions,
166
+ ) {
167
+ const middleware = createJsonSchemaValidationMiddleware(schema, options);
168
+ (this as any)[method](path, middleware, handler);
169
+ return this;
170
+ };
171
+ }
172
+ }
173
+
174
+ // ─── Feature Class ──────────────────────────────────────────────────────────
175
+
176
+ export class JsonSchemaValidationFeature implements Feature {
177
+ name = "json-schema-validation";
178
+
179
+ async initialize(kernel: Kernel): Promise<void> {
180
+ const app = kernel.getApp();
181
+ extendHonoWithJsonSchemaValidation(app);
182
+ console.log("✅ JSON Schema validation feature initialized");
183
+ }
184
+ }
185
+
186
+ export { formatAjvErrors };
@@ -0,0 +1,70 @@
1
+ import type { Feature, LoggerConfig } from "../types";
2
+ import type { Kernel } from "../kernel";
3
+ import type { Context, Next } from "hono";
4
+
5
+ declare module "hono" {
6
+ interface ContextVariableMap {
7
+ logger: any;
8
+ }
9
+ }
10
+
11
+ // Simple Logger implementation to avoid heavy dependency unless necessary
12
+ class SimpleLogger {
13
+ constructor(private config: LoggerConfig) { }
14
+
15
+ info(message: string, ...args: any[]) {
16
+ if (this.shouldLog("info")) console.log(`[INFO] ${message}`, ...args);
17
+ }
18
+ error(message: string, ...args: any[]) {
19
+ if (this.shouldLog("error")) console.error(`[ERROR] ${message}`, ...args);
20
+ }
21
+ warn(message: string, ...args: any[]) {
22
+ if (this.shouldLog("warn")) console.warn(`[WARN] ${message}`, ...args);
23
+ }
24
+ debug(message: string, ...args: any[]) {
25
+ if (this.shouldLog("debug")) console.debug(`[DEBUG] ${message}`, ...args);
26
+ }
27
+
28
+ private shouldLog(level: string) {
29
+ // Basic level check (can be improved)
30
+ return true;
31
+ }
32
+ }
33
+
34
+ export class LoggerFeature implements Feature {
35
+ name = "logger";
36
+
37
+ private config: LoggerConfig;
38
+ private logger: SimpleLogger;
39
+
40
+ constructor(config: LoggerConfig = {}) {
41
+ this.config = config;
42
+ this.logger = new SimpleLogger(config);
43
+ }
44
+
45
+ async initialize(kernel: Kernel): Promise<void> {
46
+ const app = kernel.getApp();
47
+
48
+ app.use("*", async (c: Context, next: Next) => {
49
+ c.set("logger", this.logger);
50
+ await next();
51
+ });
52
+
53
+ if (this.config.logRequests) {
54
+ app.use("*", async (c: Context, next: Next) => {
55
+ const start = Date.now();
56
+ const requestId = c.get("requestId");
57
+ this.logger.info(`Incoming request ${c.req.method} ${c.req.path}`, { requestId });
58
+
59
+ await next();
60
+
61
+ const duration = Date.now() - start;
62
+ if (this.config.logResponses) {
63
+ this.logger.info(`Request completed ${c.res.status} ${duration}ms`, { requestId });
64
+ }
65
+ });
66
+ }
67
+
68
+ console.log("✅ Logger feature initialized");
69
+ }
70
+ }
@@ -0,0 +1,107 @@
1
+ import type { Feature, OpenAPIConfig } from "../types";
2
+ import type { Kernel } from "../kernel";
3
+ import type { Context, Handler, Hono } from "hono";
4
+ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
5
+ import { ErrorCodes, errorResponse } from "../responses";
6
+
7
+ export class OpenAPIFeature implements Feature {
8
+ name = "openapi";
9
+ private app?: OpenAPIHono;
10
+ private config: OpenAPIConfig;
11
+ private kernel?: Kernel;
12
+ private autoGeneratedRoutes: Array<{ path: string; method: string; tags: string[]; summary: string }> = [];
13
+ private queuedRoutes: Array<{ route: any; handler: Handler }> = [];
14
+
15
+ constructor(config: OpenAPIConfig) {
16
+ this.config = config;
17
+ }
18
+
19
+ async initialize(kernel: Kernel): Promise<void> {
20
+ this.kernel = kernel;
21
+ this.app = new OpenAPIHono({
22
+ defaultHook: (result: any, c: Context) => {
23
+ if (!result.success) {
24
+ return c.json(errorResponse("Validation Error", ErrorCodes.VALIDATION_ERROR, result.error.flatten()), 400);
25
+ }
26
+ }
27
+ });
28
+
29
+ this.processQueuedRoutes();
30
+ console.log("✅ OpenAPIFeature initialized");
31
+ }
32
+
33
+ addRoute(route: any, handler: Handler) {
34
+ if (!this.app) {
35
+ this.queuedRoutes.push({ route, handler });
36
+ return;
37
+ }
38
+ this.app.openapi(createRoute(route), handler);
39
+ this.autoGeneratedRoutes.push({
40
+ path: route.path,
41
+ method: route.method.toUpperCase(),
42
+ tags: route.tags || [],
43
+ summary: route.summary || ""
44
+ });
45
+ }
46
+
47
+ private processQueuedRoutes() {
48
+ if (!this.app) return;
49
+ for (const { route, handler } of this.queuedRoutes) {
50
+ this.addRoute(route, handler);
51
+ }
52
+ this.queuedRoutes = [];
53
+ }
54
+
55
+ routes(app: Hono) {
56
+ if (this.app) {
57
+ app.route("/", this.app);
58
+ }
59
+
60
+ app.get("/openapi.json", (c: Context) => {
61
+ if (!this.app) return c.json({ error: "OpenAPI not initialized" }, 500);
62
+
63
+ const spec = this.app.getOpenAPIDocument({
64
+ openapi: "3.1.0",
65
+ info: {
66
+ title: this.config.title,
67
+ version: this.config.version,
68
+ description: this.config.description || "API Documentation",
69
+ contact: this.config.contact,
70
+ license: this.config.license
71
+ },
72
+ servers: this.config.servers || [{ url: "http://localhost:8000", description: "Dev Server" }],
73
+ tags: this.config.tags || [],
74
+ externalDocs: this.config.externalDocs,
75
+ security: this.config.security
76
+ });
77
+
78
+ if (this.config.securitySchemes) {
79
+ if (!spec.components) spec.components = {};
80
+ spec.components.securitySchemes = this.config.securitySchemes;
81
+ }
82
+
83
+ return c.json(spec);
84
+ });
85
+
86
+ app.get("/docs", (c) => c.html(this.generateScalarHTML()));
87
+ }
88
+
89
+ private generateScalarHTML(): string {
90
+ return `
91
+ <!DOCTYPE html>
92
+ <html>
93
+ <head>
94
+ <title>${this.config.title} - API Documentation</title>
95
+ <meta charset="utf-8" />
96
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
97
+ <style>body { margin: 0; padding: 0; }</style>
98
+ </head>
99
+ <body>
100
+ <script id="api-reference" data-url="/openapi.json" data-configuration='{"theme":"purple","layout":"modern","showSidebar":true}'></script>
101
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest"></script>
102
+ </body>
103
+ </html>`.trim();
104
+ }
105
+ }
106
+
107
+ export { createRoute, OpenAPIHono, z };
@@ -0,0 +1,128 @@
1
+ import type { Feature, PermissionsConfig, Role } from "../types";
2
+ import type { Kernel } from "../kernel";
3
+ import type { Context, Next } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
5
+
6
+ declare module "hono" {
7
+ interface ContextVariableMap {
8
+ userPermissions: string[];
9
+ checkPermission: (permission: string) => boolean;
10
+ hasRole: (role: string) => boolean;
11
+ hasAnyRole: (...roles: string[]) => boolean;
12
+ hasAllRoles: (...roles: string[]) => boolean;
13
+ }
14
+ }
15
+
16
+ const DEFAULT_ROLES: Record<string, Role> = {
17
+ admin: { name: "admin", permissions: ["*"], description: "Full access" },
18
+ user: { name: "user", permissions: ["read:own", "write:own"], description: "User access" },
19
+ guest: { name: "guest", permissions: ["read:public"], description: "Guest access" }
20
+ };
21
+
22
+ export class PermissionsFeature implements Feature {
23
+ name = "permissions";
24
+ dependencies = ["auth"];
25
+ private config: Required<PermissionsConfig>;
26
+ private roles: Map<string, Role> = new Map();
27
+
28
+ constructor(config: PermissionsConfig = {}) {
29
+ this.config = {
30
+ loadPermissions: config.loadPermissions || (async () => ["read:own"]),
31
+ loadRoles: config.loadRoles || (async () => ["user"]),
32
+ anonymousPermissions: config.anonymousPermissions || ["read:public"],
33
+ enableRBAC: config.enableRBAC ?? true,
34
+ cachePermissions: config.cachePermissions ?? true,
35
+ cacheTTL: config.cacheTTL || 3600
36
+ };
37
+ Object.values(DEFAULT_ROLES).forEach(r => this.roles.set(r.name, r));
38
+ }
39
+
40
+ async initialize(kernel: Kernel): Promise<void> {
41
+ const app = kernel.getApp();
42
+ app.use("*", async (c: Context, next: Next) => {
43
+ await this.permissionsMiddleware(c, next, kernel);
44
+ });
45
+ console.log("✅ Permissions feature initialized");
46
+ }
47
+
48
+ private async permissionsMiddleware(c: Context, next: Next, _kernel: Kernel) {
49
+ const user = c.get("user") || (c.get("session") as any)?.user;
50
+ const userId = user?.id;
51
+
52
+ let permissions: string[] = [];
53
+ let roles: string[] = [];
54
+
55
+ if (userId) {
56
+ if (this.config.cachePermissions) {
57
+ const cache = c.get("cache");
58
+ if (cache) {
59
+ const cached = await cache.get(`permissions:${userId}`);
60
+ if (cached) {
61
+ permissions = cached.permissions || [];
62
+ roles = cached.roles || [];
63
+ }
64
+ }
65
+ }
66
+
67
+ if (permissions.length === 0) {
68
+ permissions = await this.config.loadPermissions(userId);
69
+ if (this.config.enableRBAC) {
70
+ roles = await this.config.loadRoles(userId);
71
+ for (const rName of roles) {
72
+ const r = this.roles.get(rName);
73
+ if (r) permissions.push(...r.permissions);
74
+ }
75
+ }
76
+
77
+ if (this.config.cachePermissions) {
78
+ const cache = c.get("cache");
79
+ if (cache) {
80
+ // Assume cache set exists and supports object storage (json stringify maybe required depending on cache impl)
81
+ // Simple cache might require string
82
+ await cache.set(`permissions:${userId}`, { permissions, roles }, this.config.cacheTTL);
83
+ }
84
+ }
85
+ }
86
+ } else {
87
+ permissions = this.config.anonymousPermissions;
88
+ }
89
+
90
+ permissions = [...new Set(permissions)];
91
+
92
+ c.set("userPermissions", permissions);
93
+ c.set("checkPermission", (p: string) => this.checkPermission(permissions, p));
94
+ c.set("hasRole", (r: string) => roles.includes(r));
95
+ c.set("hasAnyRole", (...rs: string[]) => rs.some(r => roles.includes(r)));
96
+ c.set("hasAllRoles", (...rs: string[]) => rs.every(r => roles.includes(r)));
97
+
98
+ await next();
99
+ }
100
+
101
+ private checkPermission(userPerms: string[], required: string): boolean {
102
+ if (userPerms.includes("*")) return true;
103
+ if (userPerms.includes(required)) return true;
104
+
105
+ const parts = required.split(":");
106
+ for (let i = parts.length - 1; i > 0; i--) {
107
+ const pattern = parts.slice(0, i).join(":") + ":*";
108
+ if (userPerms.includes(pattern)) return true;
109
+ }
110
+ return false;
111
+ }
112
+ }
113
+
114
+ export function requirePermission(permission: string) {
115
+ return async (c: Context, next: Next) => {
116
+ const check = c.get("checkPermission");
117
+ if (!check || !check(permission)) throw new HTTPException(403, { message: `Missing permission: ${permission}` });
118
+ await next();
119
+ };
120
+ }
121
+
122
+ export function requireRole(role: string) {
123
+ return async (c: Context, next: Next) => {
124
+ const check = c.get("hasRole");
125
+ if (!check || !check(role)) throw new HTTPException(403, { message: `Missing role: ${role}` });
126
+ await next();
127
+ };
128
+ }
@@ -0,0 +1,173 @@
1
+ import type { Feature, RateLimitConfig } from "../types";
2
+ import type { Kernel } from "../kernel";
3
+ import type { Context, Next } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
5
+
6
+ interface RateLimitStore {
7
+ get(key: string): Promise<number | null>;
8
+ set(key: string, value: number, ttl: number): Promise<void>;
9
+ increment(key: string, ttl: number): Promise<number>;
10
+ }
11
+
12
+ class MemoryStore implements RateLimitStore {
13
+ private store = new Map<string, { count: number; expiresAt: number }>();
14
+
15
+ async get(key: string): Promise<number | null> {
16
+ const entry = this.store.get(key);
17
+ if (!entry) return null;
18
+ if (Date.now() > entry.expiresAt) {
19
+ this.store.delete(key);
20
+ return null;
21
+ }
22
+ return entry.count;
23
+ }
24
+
25
+ async set(key: string, value: number, ttl: number): Promise<void> {
26
+ this.store.set(key, { count: value, expiresAt: Date.now() + ttl });
27
+ }
28
+
29
+ async increment(key: string, ttl: number): Promise<number> {
30
+ const entry = this.store.get(key);
31
+ if (!entry || Date.now() > entry.expiresAt) {
32
+ this.store.set(key, { count: 1, expiresAt: Date.now() + ttl });
33
+ return 1;
34
+ }
35
+ entry.count++;
36
+ return entry.count;
37
+ }
38
+
39
+ cleanup(): void {
40
+ const now = Date.now();
41
+ for (const [key, entry] of this.store.entries()) {
42
+ if (now > entry.expiresAt) this.store.delete(key);
43
+ }
44
+ }
45
+ }
46
+
47
+ class CacheStoreWrapper implements RateLimitStore {
48
+ constructor(private cache: any) { }
49
+
50
+ async get(key: string): Promise<number | null> {
51
+ return await this.cache.get(key);
52
+ }
53
+
54
+ async set(key: string, value: number, ttl: number): Promise<void> {
55
+ await this.cache.set(key, value, ttl / 1000); // Cache feature expects seconds typically if using Redis, but check implementation
56
+ }
57
+
58
+ async increment(key: string, ttl: number): Promise<number> {
59
+ const ttlSeconds = Math.ceil(ttl / 1000);
60
+ const current = await this.cache.get(key);
61
+ if (current === null) {
62
+ await this.cache.set(key, 1, ttlSeconds);
63
+ return 1;
64
+ }
65
+ // Use atomic increment if available (Redis INCR)
66
+ if (this.cache.increment) return await this.cache.increment(key);
67
+
68
+ // Fallback: increment and re-set with TTL
69
+ const newVal = Number(current) + 1;
70
+ await this.cache.set(key, newVal, ttlSeconds);
71
+ return newVal;
72
+ }
73
+ }
74
+
75
+ export class RateLimitFeature implements Feature {
76
+ name = "rate-limit";
77
+ private config: Required<Omit<RateLimitConfig, "keyGenerator" | "skip" | "handler">> & {
78
+ keyGenerator?: RateLimitConfig["keyGenerator"];
79
+ skip?: RateLimitConfig["skip"];
80
+ handler?: RateLimitConfig["handler"];
81
+ };
82
+ private store?: RateLimitStore;
83
+ private cleanupInterval?: ReturnType<typeof setInterval>;
84
+
85
+ constructor(config: RateLimitConfig = {}) {
86
+ this.config = {
87
+ windowMs: config.windowMs || 15 * 60 * 1000,
88
+ max: config.max || 100,
89
+ standardHeaders: config.standardHeaders ?? true,
90
+ store: config.store || "memory",
91
+ keyGenerator: config.keyGenerator,
92
+ skip: config.skip,
93
+ handler: config.handler
94
+ };
95
+ }
96
+
97
+ async initialize(kernel: Kernel): Promise<void> {
98
+ if (this.config.store === "cache") {
99
+ const cacheFeature = kernel.getFeature("cache") as any;
100
+ if (cacheFeature?.client) {
101
+ this.store = new CacheStoreWrapper(cacheFeature.client);
102
+ } else {
103
+ console.warn("⚠️ Cache feature not available for rate-limit, falling back to memory store");
104
+ const mem = new MemoryStore();
105
+ this.store = mem;
106
+ this.cleanupInterval = setInterval(() => mem.cleanup(), 300000);
107
+ }
108
+ }
109
+
110
+ if (this.config.store === "memory") {
111
+ const mem = new MemoryStore();
112
+ this.store = mem;
113
+ this.cleanupInterval = setInterval(() => mem.cleanup(), 300000);
114
+ }
115
+
116
+ const app = kernel.getApp();
117
+ app.use("*", async (c: Context, next: Next) => {
118
+ await this.middleware(c, next);
119
+ });
120
+ console.log("✅ Rate limit feature initialized");
121
+ }
122
+
123
+ private async middleware(c: Context, next: Next) {
124
+ if (this.config.skip && this.config.skip(c)) {
125
+ await next();
126
+ return;
127
+ }
128
+
129
+ let store = this.store;
130
+ if (!store && this.config.store === "cache") {
131
+ const cache = c.get("cache");
132
+ if (cache) {
133
+ store = new CacheStoreWrapper(cache);
134
+ } else {
135
+ if (!this.store) {
136
+ this.store = new MemoryStore(); // Fallback
137
+ this.cleanupInterval = setInterval(() => (this.store as MemoryStore).cleanup(), 300000);
138
+ }
139
+ store = this.store;
140
+ }
141
+ }
142
+
143
+ if (!store) {
144
+ await next();
145
+ return;
146
+ }
147
+
148
+ const key = this.config.keyGenerator ? this.config.keyGenerator(c) : this.defaultKeyGenerator(c);
149
+ const rlKey = `rate_limit:${key}`;
150
+ const count = await store.increment(rlKey, this.config.windowMs);
151
+
152
+ if (count > this.config.max) {
153
+ if (this.config.handler) return this.config.handler(c);
154
+ throw new HTTPException(429, { message: "Too many requests" });
155
+ }
156
+
157
+ if (this.config.standardHeaders) {
158
+ c.header("X-RateLimit-Limit", String(this.config.max));
159
+ c.header("X-RateLimit-Remaining", String(Math.max(0, this.config.max - count)));
160
+ c.header("X-RateLimit-Reset", String(Math.ceil((Date.now() + this.config.windowMs) / 1000)));
161
+ }
162
+
163
+ await next();
164
+ }
165
+
166
+ private defaultKeyGenerator(c: Context): string {
167
+ return c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown";
168
+ }
169
+
170
+ async shutdown() {
171
+ if (this.cleanupInterval) clearInterval(this.cleanupInterval);
172
+ }
173
+ }