@tamayrp14/fire-api 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -2
- package/bin/fire.js +139 -0
- package/bin/fire.mjs +670 -0
- package/bun.lock +20 -0
- package/docs/GETTING_STARTED.md +150 -0
- package/docs/LAUNCH_KIT.md +236 -0
- package/docs/superpowers/plans/2026-11-14-fire-api-framework.md +144 -0
- package/docs/superpowers/specs/fire-api-design-spec.md +111 -0
- package/examples/basic-api/index.ts +177 -0
- package/package.json +68 -4
- package/src/ai/mcp.ts +140 -0
- package/src/auth/auth.ts +110 -0
- package/src/auth/crypto.ts +70 -0
- package/src/auth/jwt.ts +105 -0
- package/src/cache/cache.ts +72 -0
- package/src/cli/bench.ts +118 -0
- package/src/cli/compile.ts +29 -0
- package/src/cli/create-app.ts +284 -0
- package/src/cli/docker.ts +51 -0
- package/src/cli/generator.ts +75 -0
- package/src/cli/routes.ts +76 -0
- package/src/client.ts +120 -0
- package/src/compat/express.ts +139 -0
- package/src/core/app.ts +723 -0
- package/src/core/context.ts +218 -0
- package/src/core/error.ts +42 -0
- package/src/core/router.ts +262 -0
- package/src/db/db-interface.ts +9 -0
- package/src/db/hub.ts +256 -0
- package/src/db/memory-adapter.ts +202 -0
- package/src/db/migrator.ts +100 -0
- package/src/db/sqlite-adapter.ts +58 -0
- package/src/docs/collection.ts +57 -0
- package/src/docs/scalar-ui.ts +25 -0
- package/src/events/event-bus.ts +61 -0
- package/src/index.ts +37 -0
- package/src/jobs/cron.ts +77 -0
- package/src/jobs/queue.ts +108 -0
- package/src/logging/logger.ts +66 -0
- package/src/metrics/metrics.ts +66 -0
- package/src/modules/product/product.route.ts +36 -0
- package/src/modules/product/product.schema.ts +10 -0
- package/src/schema/mock.ts +60 -0
- package/src/schema/openapi.ts +101 -0
- package/src/schema/schema.ts +519 -0
- package/src/schema/validator.ts +5 -0
- package/src/shield/compress.ts +61 -0
- package/src/shield/cors.ts +94 -0
- package/src/shield/headers.ts +55 -0
- package/src/shield/idempotency.ts +76 -0
- package/src/shield/rate-limiter.ts +81 -0
- package/src/shield/sanitizer.ts +23 -0
- package/src/shield/webhook.ts +101 -0
- package/src/static/serve.ts +51 -0
- package/src/storage/storage.ts +176 -0
- package/src/stream/sse.ts +89 -0
- package/src/studio/studio.ts +221 -0
- package/src/types.ts +241 -0
- package/src/ws/ws.ts +118 -0
- package/test/ai-mcp.test.ts +61 -0
- package/test/auth.test.ts +108 -0
- package/test/bench.test.ts +31 -0
- package/test/cache.test.ts +36 -0
- package/test/cli-create.test.ts +64 -0
- package/test/client.test.ts +54 -0
- package/test/collection-export.test.ts +52 -0
- package/test/compat.test.ts +48 -0
- package/test/compress.test.ts +48 -0
- package/test/context.test.ts +48 -0
- package/test/db.test.ts +62 -0
- package/test/docker.test.ts +28 -0
- package/test/events.test.ts +62 -0
- package/test/file-upload.test.ts +116 -0
- package/test/hooks.test.ts +77 -0
- package/test/idempotency.test.ts +65 -0
- package/test/integration.test.ts +111 -0
- package/test/jobs.test.ts +60 -0
- package/test/metrics.test.ts +40 -0
- package/test/mock.test.ts +33 -0
- package/test/multi-tenant.test.ts +80 -0
- package/test/observability.test.ts +95 -0
- package/test/openapi.test.ts +41 -0
- package/test/orm-relations.test.ts +85 -0
- package/test/orm-shortcuts.test.ts +52 -0
- package/test/rbac.test.ts +85 -0
- package/test/router-fastpath.test.ts +42 -0
- package/test/router.test.ts +84 -0
- package/test/schema.test.ts +49 -0
- package/test/seeder-migrator.test.ts +78 -0
- package/test/shield.test.ts +55 -0
- package/test/shorthand-dx.test.ts +31 -0
- package/test/sse.test.ts +43 -0
- package/test/static-serve.test.ts +53 -0
- package/test/storage.test.ts +31 -0
- package/test/studio.test.ts +43 -0
- package/test/tx.test.ts +92 -0
- package/test/webhook.test.ts +113 -0
- package/test/ws.test.ts +54 -0
- package/tsconfig.json +19 -0
package/src/core/app.ts
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FireAppOptions,
|
|
3
|
+
FireCacheManager,
|
|
4
|
+
FireDatabase,
|
|
5
|
+
Handler,
|
|
6
|
+
HookType,
|
|
7
|
+
HttpMethod,
|
|
8
|
+
Middleware,
|
|
9
|
+
RouteOptions,
|
|
10
|
+
} from '../types';
|
|
11
|
+
import { Router } from './router';
|
|
12
|
+
import { createContext } from './context';
|
|
13
|
+
import { HttpError } from './error';
|
|
14
|
+
import { applySecurityHeaders } from '../shield/headers';
|
|
15
|
+
import { appendCorsHeaders, handleCORS } from '../shield/cors';
|
|
16
|
+
import { RateLimiter } from '../shield/rate-limiter';
|
|
17
|
+
import { sanitizePayload } from '../shield/sanitizer';
|
|
18
|
+
import { compressResponse } from '../shield/compress';
|
|
19
|
+
import { IdempotencyManager } from '../shield/idempotency';
|
|
20
|
+
import { createDatabaseHub, createProxyDatabase } from '../db/hub';
|
|
21
|
+
import { MemoryCacheManager } from '../cache/cache';
|
|
22
|
+
import { generateOpenAPISpec } from '../schema/openapi';
|
|
23
|
+
import { renderScalarDocsHTML } from '../docs/scalar-ui';
|
|
24
|
+
import { generatePostmanCollection } from '../docs/collection';
|
|
25
|
+
import { createStaticFileResponse } from '../static/serve';
|
|
26
|
+
import * as path from 'path';
|
|
27
|
+
import { createMCPServer, MCPServer } from '../ai/mcp';
|
|
28
|
+
import { BaseSchema } from '../schema/schema';
|
|
29
|
+
import { FireQueueManager } from '../jobs/queue';
|
|
30
|
+
import { FireCronScheduler, CronTask } from '../jobs/cron';
|
|
31
|
+
import { WebSocketHub, FireWebSocketHandler } from '../ws/ws';
|
|
32
|
+
import { createStorage, FireStorage } from '../storage/storage';
|
|
33
|
+
import { MetricsCollector } from '../metrics/metrics';
|
|
34
|
+
import { EventBus, EventListener } from '../events/event-bus';
|
|
35
|
+
import { UploadedFile } from '../types';
|
|
36
|
+
|
|
37
|
+
export class FireApp {
|
|
38
|
+
public router: Router;
|
|
39
|
+
public db: FireDatabase;
|
|
40
|
+
public cache: FireCacheManager;
|
|
41
|
+
public queue: FireQueueManager;
|
|
42
|
+
public cronScheduler: FireCronScheduler;
|
|
43
|
+
public wsHub: WebSocketHub;
|
|
44
|
+
public storage: FireStorage;
|
|
45
|
+
public metrics: MetricsCollector;
|
|
46
|
+
public events = new EventBus();
|
|
47
|
+
public wsHandlers: Map<string, FireWebSocketHandler> = new Map();
|
|
48
|
+
public mcpServer?: MCPServer;
|
|
49
|
+
private options: FireAppOptions;
|
|
50
|
+
private rateLimiter?: RateLimiter;
|
|
51
|
+
private idempotencyManager = new IdempotencyManager();
|
|
52
|
+
private globalMiddlewares: Middleware[] = [];
|
|
53
|
+
private hooks: Record<HookType, Array<(arg1: any, arg2?: any) => Promise<any> | any>> = {
|
|
54
|
+
onRequest: [],
|
|
55
|
+
beforeHandle: [],
|
|
56
|
+
afterHandle: [],
|
|
57
|
+
onError: [],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
constructor(options: FireAppOptions = {}) {
|
|
61
|
+
this.options = {
|
|
62
|
+
port: 3000,
|
|
63
|
+
docs: '/docs',
|
|
64
|
+
openapiPath: '/openapi.json',
|
|
65
|
+
metricsPath: '/metrics',
|
|
66
|
+
...options,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
this.router = new Router(this.options.prefix || '');
|
|
70
|
+
this.db = createDatabaseHub(this.options.database);
|
|
71
|
+
this.cache = new MemoryCacheManager();
|
|
72
|
+
this.queue = new FireQueueManager();
|
|
73
|
+
this.cronScheduler = new FireCronScheduler();
|
|
74
|
+
this.wsHub = new WebSocketHub();
|
|
75
|
+
this.storage = createStorage(this.options.storage);
|
|
76
|
+
this.metrics = new MetricsCollector();
|
|
77
|
+
|
|
78
|
+
// Initialize Security Shield
|
|
79
|
+
if (this.options.security?.rateLimit) {
|
|
80
|
+
this.rateLimiter = new RateLimiter(this.options.security.rateLimit);
|
|
81
|
+
} else if (this.options.security?.enabled !== false) {
|
|
82
|
+
// Default rate limit: 1000 requests per minute
|
|
83
|
+
this.rateLimiter = new RateLimiter({ max: 1000, window: '1m' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Auto-mount Docs, OpenAPI, and Metrics endpoints if configured
|
|
87
|
+
this.setupDocumentationRoutes();
|
|
88
|
+
this.setupMetricsRoute();
|
|
89
|
+
this.setupHealthRoutes();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private setupMetricsRoute(): void {
|
|
93
|
+
const metricsPath = this.options.metricsPath;
|
|
94
|
+
if (metricsPath) {
|
|
95
|
+
this.router.get(metricsPath, {
|
|
96
|
+
handler: (ctx) => {
|
|
97
|
+
const content = this.metrics.formatPrometheus();
|
|
98
|
+
return ctx.text(content, 200, {
|
|
99
|
+
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private setupDocumentationRoutes(): void {
|
|
107
|
+
const openapiPath = this.options.openapiPath;
|
|
108
|
+
const docsPath = this.options.docs;
|
|
109
|
+
|
|
110
|
+
if (openapiPath) {
|
|
111
|
+
this.router.get(openapiPath, {
|
|
112
|
+
handler: (ctx) => {
|
|
113
|
+
const spec = generateOpenAPISpec(this.router, {
|
|
114
|
+
title: 'FireAPI Documentation',
|
|
115
|
+
version: '1.0.0',
|
|
116
|
+
});
|
|
117
|
+
return ctx.json(spec);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (docsPath && openapiPath) {
|
|
123
|
+
this.router.get(docsPath, {
|
|
124
|
+
handler: (ctx) => {
|
|
125
|
+
const html = renderScalarDocsHTML(openapiPath, 'FireAPI Interactive Docs');
|
|
126
|
+
return ctx.html(html);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this.router.get('/docs/collection.json', {
|
|
131
|
+
handler: (ctx) => {
|
|
132
|
+
const collection = generatePostmanCollection(this.router, {
|
|
133
|
+
name: 'FireAPI Generated Collection',
|
|
134
|
+
});
|
|
135
|
+
return ctx.json(collection);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public serveStatic(routePrefix: string, localDirectory: string): this {
|
|
142
|
+
const normalizedPrefix = routePrefix.endsWith('/') ? routePrefix.slice(0, -1) : routePrefix;
|
|
143
|
+
this.router.get(`${normalizedPrefix}/*`, {
|
|
144
|
+
handler: (ctx) => {
|
|
145
|
+
const url = new URL(ctx.req.url);
|
|
146
|
+
const subPath = url.pathname.slice(normalizedPrefix.length).replace(/^\/+/, '');
|
|
147
|
+
const filePath = path.resolve(localDirectory, subPath);
|
|
148
|
+
const res = createStaticFileResponse(filePath);
|
|
149
|
+
if (!res) {
|
|
150
|
+
return ctx.notFound(`File ${subPath} not found`);
|
|
151
|
+
}
|
|
152
|
+
return res;
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public serveSPA(routePath: string, htmlFilePath: string): this {
|
|
159
|
+
this.router.get(routePath, {
|
|
160
|
+
handler: (ctx) => {
|
|
161
|
+
const res = createStaticFileResponse(path.resolve(htmlFilePath));
|
|
162
|
+
if (!res) {
|
|
163
|
+
return ctx.notFound('SPA index file not found');
|
|
164
|
+
}
|
|
165
|
+
return res;
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private setupHealthRoutes(): void {
|
|
172
|
+
if (this.options.health === false) return;
|
|
173
|
+
const healthConfig = typeof this.options.health === 'object' ? this.options.health : {};
|
|
174
|
+
const livenessPath = healthConfig.livenessPath || '/healthz';
|
|
175
|
+
const readinessPath = healthConfig.readinessPath || '/readyz';
|
|
176
|
+
|
|
177
|
+
// Liveness Probe (process is alive)
|
|
178
|
+
this.router.get(livenessPath, {
|
|
179
|
+
handler: (ctx) => {
|
|
180
|
+
return ctx.ok({
|
|
181
|
+
status: 'ok',
|
|
182
|
+
uptime: typeof process !== 'undefined' && process.uptime ? Math.floor(process.uptime()) : 0,
|
|
183
|
+
timestamp: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Readiness Probe (DB & storage dependencies ready)
|
|
189
|
+
this.router.get(readinessPath, {
|
|
190
|
+
handler: async (ctx) => {
|
|
191
|
+
const checks: Record<string, 'ok' | 'down'> = {};
|
|
192
|
+
let isHealthy = true;
|
|
193
|
+
|
|
194
|
+
if (healthConfig.checkDatabase !== false) {
|
|
195
|
+
try {
|
|
196
|
+
await this.db.raw('SELECT 1');
|
|
197
|
+
checks.database = 'ok';
|
|
198
|
+
} catch {
|
|
199
|
+
checks.database = 'down';
|
|
200
|
+
isHealthy = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (healthConfig.checkStorage) {
|
|
205
|
+
try {
|
|
206
|
+
await this.storage.exists('healthcheck');
|
|
207
|
+
checks.storage = 'ok';
|
|
208
|
+
} catch {
|
|
209
|
+
checks.storage = 'down';
|
|
210
|
+
isHealthy = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (healthConfig.customChecks) {
|
|
215
|
+
for (const checkFn of healthConfig.customChecks) {
|
|
216
|
+
try {
|
|
217
|
+
const res = await checkFn();
|
|
218
|
+
checks[res.name] = res.status === 'ok' ? 'ok' : 'down';
|
|
219
|
+
if (res.status === 'down') isHealthy = false;
|
|
220
|
+
} catch {
|
|
221
|
+
isHealthy = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!isHealthy) {
|
|
227
|
+
return ctx.json({ status: 'down', checks }, 503);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return ctx.ok({ status: 'ok', checks });
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
public hook(type: HookType, handler: (arg1: any, arg2?: any) => Promise<any> | any): this {
|
|
236
|
+
this.hooks[type].push(handler);
|
|
237
|
+
return this;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public use(middleware: Middleware): this;
|
|
241
|
+
public use(subRouter: Router): this;
|
|
242
|
+
public use(prefix: string, subRouter: Router): this;
|
|
243
|
+
public use(arg1: string | Middleware | Router, arg2?: Router): this {
|
|
244
|
+
if (typeof arg1 === 'function') {
|
|
245
|
+
this.globalMiddlewares.push(arg1);
|
|
246
|
+
return this;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (arg1 instanceof Router) {
|
|
250
|
+
this.router.use('', arg1);
|
|
251
|
+
return this;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (typeof arg1 === 'string' && arg2 instanceof Router) {
|
|
255
|
+
this.router.use(arg1, arg2);
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
public cron(expression: string, handler: (app: FireApp) => Promise<void> | void): CronTask {
|
|
263
|
+
return this.cronScheduler.schedule(expression, () => handler(this));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public ws(path: string, handler: FireWebSocketHandler): this {
|
|
267
|
+
this.wsHandlers.set(path, handler);
|
|
268
|
+
return this;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
public on<T = any>(event: string, listener: EventListener<T>): () => void {
|
|
272
|
+
return this.events.on(event, listener);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
public async emit<T = any>(event: string, payload: T): Promise<void> {
|
|
276
|
+
return this.events.emit(event, payload);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
public get(path: string, optionsOrHandler: RouteOptions | Handler): this {
|
|
280
|
+
this.router.get(path, optionsOrHandler);
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public post(path: string, optionsOrHandler: RouteOptions | Handler): this {
|
|
285
|
+
this.router.post(path, optionsOrHandler);
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
public put(path: string, optionsOrHandler: RouteOptions | Handler): this {
|
|
290
|
+
this.router.put(path, optionsOrHandler);
|
|
291
|
+
return this;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
public patch(path: string, optionsOrHandler: RouteOptions | Handler): this {
|
|
295
|
+
this.router.patch(path, optionsOrHandler);
|
|
296
|
+
return this;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
public delete(path: string, optionsOrHandler: RouteOptions | Handler): this {
|
|
300
|
+
this.router.delete(path, optionsOrHandler);
|
|
301
|
+
return this;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
public enableMCP(options: { name?: string; version?: string; endpoint?: string } = {}): this {
|
|
305
|
+
this.mcpServer = createMCPServer(this.router, options);
|
|
306
|
+
const endpoint = options.endpoint || '/mcp';
|
|
307
|
+
|
|
308
|
+
this.router.post(endpoint, {
|
|
309
|
+
handler: async (ctx) => {
|
|
310
|
+
const body = ctx.body;
|
|
311
|
+
if (!body || typeof body !== 'object') {
|
|
312
|
+
return ctx.badRequest('Invalid JSON-RPC request');
|
|
313
|
+
}
|
|
314
|
+
const result = await this.mcpServer!.handleMCPProtocol(body);
|
|
315
|
+
return ctx.json(result);
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The pure Web Standard fetch handler: (request: Request) => Promise<Response>
|
|
324
|
+
* Runs natively on Bun, Node.js (via adapter), Cloudflare Workers, and Deno!
|
|
325
|
+
*/
|
|
326
|
+
public async handle(request: Request): Promise<Response> {
|
|
327
|
+
const startTime = performance.now();
|
|
328
|
+
const url = new URL(request.url);
|
|
329
|
+
|
|
330
|
+
// 1. CORS Preflight & Checking
|
|
331
|
+
if (this.options.security?.cors !== false) {
|
|
332
|
+
const corsConfig = typeof this.options.security?.cors === 'object' ? this.options.security.cors : {};
|
|
333
|
+
const corsPreflight = handleCORS(request, corsConfig);
|
|
334
|
+
if (corsPreflight) return corsPreflight;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 2. Global Rate Limiter
|
|
338
|
+
if (this.rateLimiter) {
|
|
339
|
+
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || '127.0.0.1';
|
|
340
|
+
const limitCheck = this.rateLimiter.check(clientIp);
|
|
341
|
+
if (!limitCheck.allowed) {
|
|
342
|
+
return new HttpError(429, 'Too Many Requests', { retryAfter: limitCheck.retryAfter }).toResponse();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 3. Match Route in Radix Router
|
|
347
|
+
const method = request.method as HttpMethod;
|
|
348
|
+
const match = this.router.match(method, url.pathname);
|
|
349
|
+
|
|
350
|
+
if (!match) {
|
|
351
|
+
const notFoundRes = new HttpError(404, `Cannot ${method} ${url.pathname}`).toResponse();
|
|
352
|
+
return this.finalizeResponse(notFoundRes, request, startTime);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check Idempotency Key
|
|
356
|
+
const idempotencyOption = match.routeOptions.idempotent;
|
|
357
|
+
const idempotencyKey = idempotencyOption
|
|
358
|
+
? (request.headers.get('x-idempotency-key') || request.headers.get('idempotency-key'))
|
|
359
|
+
: null;
|
|
360
|
+
|
|
361
|
+
if (idempotencyKey) {
|
|
362
|
+
const cached = this.idempotencyManager.get(idempotencyKey);
|
|
363
|
+
if (cached) {
|
|
364
|
+
const replayRes = this.idempotencyManager.createResponse(cached);
|
|
365
|
+
return this.finalizeResponse(replayRes, request, startTime);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (this.idempotencyManager.isProcessing(idempotencyKey)) {
|
|
369
|
+
return this.finalizeResponse(
|
|
370
|
+
new HttpError(409, 'A request with this Idempotency-Key is currently being processed').toResponse(),
|
|
371
|
+
request,
|
|
372
|
+
startTime
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
this.idempotencyManager.markProcessing(idempotencyKey);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
// 4. Parse & Sanitize Request Body (if any)
|
|
381
|
+
let parsedBody: any = null;
|
|
382
|
+
let rawBody = '';
|
|
383
|
+
const uploadedFiles: Record<string, UploadedFile> = {};
|
|
384
|
+
|
|
385
|
+
if (method !== 'GET' && method !== 'HEAD' && request.body) {
|
|
386
|
+
const contentType = request.headers.get('content-type') || '';
|
|
387
|
+
|
|
388
|
+
if (contentType.includes('multipart/form-data')) {
|
|
389
|
+
try {
|
|
390
|
+
const formData = await request.formData();
|
|
391
|
+
parsedBody = {};
|
|
392
|
+
formData.forEach((value, key) => {
|
|
393
|
+
if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
|
|
394
|
+
const fileBlob = value as Blob & { name?: string };
|
|
395
|
+
const fileName = fileBlob.name || 'upload.bin';
|
|
396
|
+
const uploadedFile: UploadedFile = {
|
|
397
|
+
name: fileName,
|
|
398
|
+
size: fileBlob.size,
|
|
399
|
+
type: fileBlob.type || 'application/octet-stream',
|
|
400
|
+
bytes: async () => new Uint8Array(await fileBlob.arrayBuffer()),
|
|
401
|
+
text: async () => fileBlob.text(),
|
|
402
|
+
store: async (destinationDir = 'uploads', opts = {}) => {
|
|
403
|
+
const destName = opts.fileName || `${Date.now()}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
|
404
|
+
const targetPath = destinationDir ? `${destinationDir}/${destName}` : destName;
|
|
405
|
+
const content = new Uint8Array(await fileBlob.arrayBuffer());
|
|
406
|
+
return this.storage.put(targetPath, content, { contentType: fileBlob.type });
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
uploadedFiles[key] = uploadedFile;
|
|
410
|
+
parsedBody[key] = uploadedFile;
|
|
411
|
+
} else {
|
|
412
|
+
parsedBody[key] = value;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
} catch {
|
|
416
|
+
return this.finalizeResponse(
|
|
417
|
+
new HttpError(400, 'Malformed multipart form data in request').toResponse(),
|
|
418
|
+
request,
|
|
419
|
+
startTime
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
try {
|
|
424
|
+
rawBody = await request.text();
|
|
425
|
+
if (contentType.includes('application/json') && rawBody.trim()) {
|
|
426
|
+
const rawJson = JSON.parse(rawBody);
|
|
427
|
+
// Prototype Pollution Guard
|
|
428
|
+
parsedBody = this.options.security?.prototypePollutionGuard !== false
|
|
429
|
+
? sanitizePayload(rawJson)
|
|
430
|
+
: rawJson;
|
|
431
|
+
} else {
|
|
432
|
+
parsedBody = rawBody;
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
return this.finalizeResponse(
|
|
436
|
+
new HttpError(400, 'Malformed JSON in request body').toResponse(),
|
|
437
|
+
request,
|
|
438
|
+
startTime
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 5. Schema Validation (Params, Query, Body)
|
|
445
|
+
const schema = match.routeOptions.schema;
|
|
446
|
+
if (schema) {
|
|
447
|
+
// Body validation
|
|
448
|
+
if (schema.body && schema.body instanceof BaseSchema) {
|
|
449
|
+
const bodyVal = schema.body.validate(parsedBody);
|
|
450
|
+
if (!bodyVal.success) {
|
|
451
|
+
return this.finalizeResponse(
|
|
452
|
+
new HttpError(400, 'Validation Error', bodyVal.errors).toResponse(),
|
|
453
|
+
request,
|
|
454
|
+
startTime
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
parsedBody = bodyVal.data;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Params validation
|
|
461
|
+
if (schema.params && schema.params instanceof BaseSchema) {
|
|
462
|
+
const paramsVal = schema.params.validate(match.params);
|
|
463
|
+
if (!paramsVal.success) {
|
|
464
|
+
return this.finalizeResponse(
|
|
465
|
+
new HttpError(400, 'Invalid Path Parameters', paramsVal.errors).toResponse(),
|
|
466
|
+
request,
|
|
467
|
+
startTime
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
match.params = paramsVal.data;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Multi-Tenant Context Resolution
|
|
475
|
+
let tenantObj: { id: string } | undefined;
|
|
476
|
+
let effectiveDb = this.db;
|
|
477
|
+
if (this.options.multiTenant) {
|
|
478
|
+
const headerName = this.options.multiTenant.header || 'x-tenant-id';
|
|
479
|
+
const queryName = this.options.multiTenant.query || 'tenant';
|
|
480
|
+
const tenantId = request.headers.get(headerName) || url.searchParams.get(queryName);
|
|
481
|
+
if (tenantId) {
|
|
482
|
+
tenantObj = { id: tenantId };
|
|
483
|
+
if (this.options.multiTenant.autoScopeDB !== false) {
|
|
484
|
+
effectiveDb = createProxyDatabase(this.db, { tenantId });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 6. Construct FireContext
|
|
490
|
+
const ctx = createContext({
|
|
491
|
+
req: request,
|
|
492
|
+
params: match.params,
|
|
493
|
+
db: effectiveDb,
|
|
494
|
+
cache: this.cache,
|
|
495
|
+
queue: this.queue,
|
|
496
|
+
storage: this.storage,
|
|
497
|
+
body: parsedBody,
|
|
498
|
+
files: uploadedFiles,
|
|
499
|
+
rawBody,
|
|
500
|
+
tenant: tenantObj,
|
|
501
|
+
emit: (event, payload) => this.events.emit(event, payload),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Lifecycle Hook: onRequest
|
|
505
|
+
for (const hook of this.hooks.onRequest) {
|
|
506
|
+
const hookRes = await hook(ctx);
|
|
507
|
+
if (hookRes instanceof Response) {
|
|
508
|
+
return this.finalizeResponse(hookRes, request, startTime, ctx.requestId);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 7. Check Route Cache
|
|
513
|
+
const cacheRule = match.routeOptions.cache;
|
|
514
|
+
let cacheKey = '';
|
|
515
|
+
if (method === 'GET' && cacheRule) {
|
|
516
|
+
cacheKey = `route:${url.pathname}${url.search}`;
|
|
517
|
+
const cached = await this.cache.get(cacheKey);
|
|
518
|
+
if (cached) {
|
|
519
|
+
const cachedRes = ctx.json(cached);
|
|
520
|
+
cachedRes.headers.set('X-Cache', 'HIT');
|
|
521
|
+
return this.finalizeResponse(cachedRes, request, startTime, ctx.requestId);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// 8. Execute Middleware Chain & Handler
|
|
526
|
+
const middlewares = [...this.globalMiddlewares, ...match.middlewares];
|
|
527
|
+
let currentIdx = 0;
|
|
528
|
+
|
|
529
|
+
const next = async (): Promise<Response> => {
|
|
530
|
+
if (currentIdx < middlewares.length) {
|
|
531
|
+
const mw = middlewares[currentIdx++];
|
|
532
|
+
return mw(ctx, next);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Lifecycle Hook: beforeHandle
|
|
536
|
+
for (const hook of this.hooks.beforeHandle) {
|
|
537
|
+
const hookRes = await hook(ctx);
|
|
538
|
+
if (hookRes instanceof Response) {
|
|
539
|
+
return hookRes;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Check role requirements (RBAC)
|
|
544
|
+
if (match.routeOptions.roles && match.routeOptions.roles.length > 0) {
|
|
545
|
+
const userRole = ctx.user?.role;
|
|
546
|
+
const userRoles = Array.isArray(ctx.user?.roles) ? ctx.user.roles : (userRole ? [userRole] : []);
|
|
547
|
+
const hasRole = match.routeOptions.roles.some((r) => userRoles.includes(r));
|
|
548
|
+
if (!hasRole) {
|
|
549
|
+
return ctx.forbidden('Insufficient permissions');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Check dynamic policy gate
|
|
554
|
+
if (match.routeOptions.can) {
|
|
555
|
+
const isAllowed = await match.routeOptions.can(ctx.user, ctx);
|
|
556
|
+
if (!isAllowed) {
|
|
557
|
+
return ctx.forbidden('Access denied by policy gate');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Call final handler
|
|
562
|
+
const handlerResult = await match.handler(ctx);
|
|
563
|
+
if (handlerResult instanceof Response) {
|
|
564
|
+
return handlerResult;
|
|
565
|
+
}
|
|
566
|
+
if (typeof handlerResult === 'string') {
|
|
567
|
+
return ctx.text(handlerResult);
|
|
568
|
+
}
|
|
569
|
+
return ctx.ok(handlerResult);
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
let resultResponse = await next();
|
|
573
|
+
|
|
574
|
+
// Lifecycle Hook: afterHandle
|
|
575
|
+
for (const hook of this.hooks.afterHandle) {
|
|
576
|
+
const modified = await hook(ctx, resultResponse);
|
|
577
|
+
if (modified instanceof Response) {
|
|
578
|
+
resultResponse = modified;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// If caching enabled for this route, store payload
|
|
583
|
+
if (method === 'GET' && cacheRule && resultResponse.ok) {
|
|
584
|
+
try {
|
|
585
|
+
const cloned = resultResponse.clone();
|
|
586
|
+
const json = await cloned.json();
|
|
587
|
+
const ttlSecs = typeof cacheRule.ttl === 'number' ? cacheRule.ttl : 60;
|
|
588
|
+
await this.cache.set(cacheKey, json, ttlSecs, cacheRule.tags || []);
|
|
589
|
+
} catch {}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// If idempotency enabled and key provided, cache response
|
|
593
|
+
if (idempotencyKey && resultResponse.ok) {
|
|
594
|
+
const ttl = typeof idempotencyOption === 'object' && idempotencyOption.ttl ? idempotencyOption.ttl : 86400;
|
|
595
|
+
await this.idempotencyManager.storeResponse(idempotencyKey, resultResponse, ttl);
|
|
596
|
+
} else if (idempotencyKey) {
|
|
597
|
+
this.idempotencyManager.release(idempotencyKey);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return this.finalizeResponse(resultResponse, request, startTime, ctx.requestId);
|
|
601
|
+
} catch (err: any) {
|
|
602
|
+
for (const hook of this.hooks.onError) {
|
|
603
|
+
try {
|
|
604
|
+
await hook(err);
|
|
605
|
+
} catch {}
|
|
606
|
+
}
|
|
607
|
+
if (idempotencyKey) {
|
|
608
|
+
this.idempotencyManager.release(idempotencyKey);
|
|
609
|
+
}
|
|
610
|
+
if (err instanceof HttpError) {
|
|
611
|
+
return this.finalizeResponse(err.toResponse(), request, startTime);
|
|
612
|
+
}
|
|
613
|
+
const internalRes = new HttpError(500, err?.message || 'Internal Server Error').toResponse();
|
|
614
|
+
return this.finalizeResponse(internalRes, request, startTime);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private async finalizeResponse(response: Response, request: Request, startTime: number, reqId?: string): Promise<Response> {
|
|
619
|
+
// 1. Calculate and set response time & request ID headers
|
|
620
|
+
const durationMs = (performance.now() - startTime).toFixed(2);
|
|
621
|
+
const headers = new Headers(response.headers);
|
|
622
|
+
headers.set('X-Response-Time', `${durationMs}ms`);
|
|
623
|
+
|
|
624
|
+
const effectiveReqId = reqId || request.headers.get('x-request-id');
|
|
625
|
+
if (effectiveReqId) {
|
|
626
|
+
headers.set('X-Request-ID', effectiveReqId);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Record metrics
|
|
630
|
+
try {
|
|
631
|
+
const url = new URL(request.url);
|
|
632
|
+
this.metrics.recordRequest(request.method, url.pathname, response.status, parseFloat(durationMs));
|
|
633
|
+
} catch {}
|
|
634
|
+
|
|
635
|
+
let finalRes = new Response(response.body, {
|
|
636
|
+
status: response.status,
|
|
637
|
+
statusText: response.statusText,
|
|
638
|
+
headers,
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// 2. Append Security Headers
|
|
642
|
+
if (this.options.security?.headers !== false) {
|
|
643
|
+
finalRes = applySecurityHeaders(finalRes, this.options.security?.headers);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// 3. Append CORS Headers
|
|
647
|
+
if (this.options.security?.cors !== false) {
|
|
648
|
+
const corsConfig = typeof this.options.security?.cors === 'object' ? this.options.security.cors : {};
|
|
649
|
+
finalRes = appendCorsHeaders(finalRes, request, corsConfig);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// 4. Stream Compression
|
|
653
|
+
if (this.options.compression) {
|
|
654
|
+
const compConfig = typeof this.options.compression === 'object' ? this.options.compression : {};
|
|
655
|
+
finalRes = await compressResponse(finalRes, request, compConfig);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return finalRes;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
public listen(port?: number, callback?: () => void): any {
|
|
662
|
+
const listenPort = port || this.options.port || 3000;
|
|
663
|
+
|
|
664
|
+
// If running in Bun
|
|
665
|
+
if (typeof (globalThis as any).Bun !== 'undefined') {
|
|
666
|
+
const server = (globalThis as any).Bun.serve({
|
|
667
|
+
port: listenPort,
|
|
668
|
+
fetch: (req: Request) => this.handle(req),
|
|
669
|
+
});
|
|
670
|
+
callback?.();
|
|
671
|
+
return server;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// If running in Node.js
|
|
675
|
+
const http = require('node:http');
|
|
676
|
+
const { createServer } = http;
|
|
677
|
+
|
|
678
|
+
const server = createServer(async (req: any, res: any) => {
|
|
679
|
+
try {
|
|
680
|
+
const protocol = req.socket?.encrypted ? 'https' : 'http';
|
|
681
|
+
const fullUrl = `${protocol}://${req.headers.host || 'localhost'}${req.url}`;
|
|
682
|
+
|
|
683
|
+
// Buffer incoming body for Node.js
|
|
684
|
+
const chunks: Uint8Array[] = [];
|
|
685
|
+
for await (const chunk of req) {
|
|
686
|
+
chunks.push(chunk);
|
|
687
|
+
}
|
|
688
|
+
const bodyBuffer = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
|
689
|
+
|
|
690
|
+
const webReq = new Request(fullUrl, {
|
|
691
|
+
method: req.method,
|
|
692
|
+
headers: req.headers,
|
|
693
|
+
body: bodyBuffer,
|
|
694
|
+
// @ts-ignore
|
|
695
|
+
duplex: 'half',
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const webRes = await this.handle(webReq);
|
|
699
|
+
|
|
700
|
+
res.statusCode = webRes.status;
|
|
701
|
+
webRes.headers.forEach((value, key) => {
|
|
702
|
+
res.setHeader(key, value);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const responseArrayBuffer = await webRes.arrayBuffer();
|
|
706
|
+
res.end(Buffer.from(responseArrayBuffer));
|
|
707
|
+
} catch (err: any) {
|
|
708
|
+
res.statusCode = 500;
|
|
709
|
+
res.end(JSON.stringify({ error: err?.message || 'Server Error' }));
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
server.listen(listenPort, () => {
|
|
714
|
+
callback?.();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
return server;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function createApp(options?: FireAppOptions): FireApp {
|
|
722
|
+
return new FireApp(options);
|
|
723
|
+
}
|