@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface SecurityHeaderOptions {
|
|
2
|
+
contentSecurityPolicy?: boolean | string;
|
|
3
|
+
frameOptions?: 'DENY' | 'SAMEORIGIN' | string;
|
|
4
|
+
nosniff?: boolean;
|
|
5
|
+
hsts?: boolean | { maxAge: number; includeSubDomains?: boolean };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function applySecurityHeaders(
|
|
9
|
+
response: Response,
|
|
10
|
+
options?: SecurityHeaderOptions | boolean
|
|
11
|
+
): Response {
|
|
12
|
+
if (options === false) return response;
|
|
13
|
+
|
|
14
|
+
const headers = new Headers(response.headers);
|
|
15
|
+
|
|
16
|
+
// X-Content-Type-Options: nosniff
|
|
17
|
+
if (!headers.has('X-Content-Type-Options')) {
|
|
18
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// X-Frame-Options: SAMEORIGIN
|
|
22
|
+
if (!headers.has('X-Frame-Options')) {
|
|
23
|
+
const frameOpt = typeof options === 'object' && options.frameOptions ? options.frameOptions : 'SAMEORIGIN';
|
|
24
|
+
headers.set('X-Frame-Options', frameOpt);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// X-XSS-Protection: 0 (Modern standard according to MDN and OWASP)
|
|
28
|
+
if (!headers.has('X-XSS-Protection')) {
|
|
29
|
+
headers.set('X-XSS-Protection', '0');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// HSTS (Strict-Transport-Security)
|
|
33
|
+
if (!headers.has('Strict-Transport-Security')) {
|
|
34
|
+
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Referrer-Policy
|
|
38
|
+
if (!headers.has('Referrer-Policy')) {
|
|
39
|
+
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Content-Security-Policy default
|
|
43
|
+
if (!headers.has('Content-Security-Policy')) {
|
|
44
|
+
const csp = typeof options === 'object' && typeof options.contentSecurityPolicy === 'string'
|
|
45
|
+
? options.contentSecurityPolicy
|
|
46
|
+
: "default-src 'self'";
|
|
47
|
+
headers.set('Content-Security-Policy', csp);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new Response(response.body, {
|
|
51
|
+
status: response.status,
|
|
52
|
+
statusText: response.statusText,
|
|
53
|
+
headers,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface CachedIdempotentResponse {
|
|
2
|
+
status: number;
|
|
3
|
+
statusText?: string;
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
body: string;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class IdempotencyManager {
|
|
10
|
+
private inFlightKeys = new Set<string>();
|
|
11
|
+
private cache = new Map<string, CachedIdempotentResponse>();
|
|
12
|
+
|
|
13
|
+
public isProcessing(key: string): boolean {
|
|
14
|
+
return this.inFlightKeys.has(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public markProcessing(key: string): void {
|
|
18
|
+
this.inFlightKeys.add(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public release(key: string): void {
|
|
22
|
+
this.inFlightKeys.delete(key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public get(key: string): CachedIdempotentResponse | null {
|
|
26
|
+
const item = this.cache.get(key);
|
|
27
|
+
if (!item) return null;
|
|
28
|
+
|
|
29
|
+
if (Date.now() > item.expiresAt) {
|
|
30
|
+
this.cache.delete(key);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return item;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public async storeResponse(key: string, response: Response, ttlSeconds = 86400): Promise<void> {
|
|
38
|
+
try {
|
|
39
|
+
const cloned = response.clone();
|
|
40
|
+
const bodyText = await cloned.text();
|
|
41
|
+
const headersRecord: Record<string, string> = {};
|
|
42
|
+
|
|
43
|
+
response.headers.forEach((val, k) => {
|
|
44
|
+
headersRecord[k] = val;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this.cache.set(key, {
|
|
48
|
+
status: response.status,
|
|
49
|
+
statusText: response.statusText,
|
|
50
|
+
headers: headersRecord,
|
|
51
|
+
body: bodyText,
|
|
52
|
+
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore if response body cannot be read
|
|
56
|
+
} finally {
|
|
57
|
+
this.release(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public createResponse(cached: CachedIdempotentResponse): Response {
|
|
62
|
+
const headers = new Headers(cached.headers);
|
|
63
|
+
headers.set('X-Idempotent-Replay', 'true');
|
|
64
|
+
|
|
65
|
+
return new Response(cached.body, {
|
|
66
|
+
status: cached.status,
|
|
67
|
+
statusText: cached.statusText,
|
|
68
|
+
headers,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public clear(): void {
|
|
73
|
+
this.inFlightKeys.clear();
|
|
74
|
+
this.cache.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface RateLimiterOptions {
|
|
2
|
+
max: number;
|
|
3
|
+
windowMs?: number;
|
|
4
|
+
window?: string; // '1m', '15m', '1h', etc.
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseDuration(durationStr: string): number {
|
|
8
|
+
const match = durationStr.match(/^(\d+)(s|m|h|d)?$/);
|
|
9
|
+
if (!match) return 60000; // default 1m
|
|
10
|
+
const val = parseInt(match[1], 10);
|
|
11
|
+
const unit = match[2];
|
|
12
|
+
switch (unit) {
|
|
13
|
+
case 's': return val * 1000;
|
|
14
|
+
case 'm': return val * 60 * 1000;
|
|
15
|
+
case 'h': return val * 3600 * 1000;
|
|
16
|
+
case 'd': return val * 86400 * 1000;
|
|
17
|
+
default: return val * 1000;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RateLimiter {
|
|
22
|
+
private hits: Map<string, number[]> = new Map();
|
|
23
|
+
private max: number;
|
|
24
|
+
private windowMs: number;
|
|
25
|
+
private cleanupTimer?: any;
|
|
26
|
+
|
|
27
|
+
constructor(options: RateLimiterOptions) {
|
|
28
|
+
this.max = options.max;
|
|
29
|
+
this.windowMs = options.windowMs || (options.window ? parseDuration(options.window) : 60000);
|
|
30
|
+
|
|
31
|
+
// Periodically prune stale keys to guarantee zero memory leaks
|
|
32
|
+
if (typeof setInterval !== 'undefined') {
|
|
33
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), this.windowMs * 2);
|
|
34
|
+
if (this.cleanupTimer.unref) {
|
|
35
|
+
this.cleanupTimer.unref();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public check(key: string): { allowed: boolean; remaining: number; retryAfter?: number } {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const windowStart = now - this.windowMs;
|
|
43
|
+
|
|
44
|
+
let timestamps = this.hits.get(key) || [];
|
|
45
|
+
// Filter timestamps within current sliding window
|
|
46
|
+
timestamps = timestamps.filter((t) => t > windowStart);
|
|
47
|
+
|
|
48
|
+
if (timestamps.length >= this.max) {
|
|
49
|
+
const oldestHit = timestamps[0];
|
|
50
|
+
const retryAfter = Math.ceil((oldestHit + this.windowMs - now) / 1000);
|
|
51
|
+
return { allowed: false, remaining: 0, retryAfter: Math.max(retryAfter, 1) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
timestamps.push(now);
|
|
55
|
+
this.hits.set(key, timestamps);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
allowed: true,
|
|
59
|
+
remaining: this.max - timestamps.length,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public cleanup(): void {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const windowStart = now - this.windowMs;
|
|
66
|
+
for (const [key, timestamps] of this.hits.entries()) {
|
|
67
|
+
const valid = timestamps.filter((t) => t > windowStart);
|
|
68
|
+
if (valid.length === 0) {
|
|
69
|
+
this.hits.delete(key);
|
|
70
|
+
} else {
|
|
71
|
+
this.hits.set(key, valid);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public destroy(): void {
|
|
77
|
+
if (this.cleanupTimer) {
|
|
78
|
+
clearInterval(this.cleanupTimer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function sanitizePayload<T = any>(data: T): T {
|
|
2
|
+
if (data === null || typeof data !== 'object') {
|
|
3
|
+
return data;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (Array.isArray(data)) {
|
|
7
|
+
return data.map((item) => sanitizePayload(item)) as any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Create clean dictionary with null prototype to prevent prototype poisoning
|
|
11
|
+
const clean: Record<string, any> = {};
|
|
12
|
+
|
|
13
|
+
for (const key of Object.keys(data)) {
|
|
14
|
+
// Strip dangerous prototype keys
|
|
15
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const val = (data as any)[key];
|
|
19
|
+
clean[key] = typeof val === 'object' && val !== null ? sanitizePayload(val) : val;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return clean as T;
|
|
23
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export async function computeHMAC(
|
|
2
|
+
payload: string | Uint8Array,
|
|
3
|
+
secret: string,
|
|
4
|
+
algorithm: 'SHA-256' | 'SHA-1' | 'SHA-512' = 'SHA-256'
|
|
5
|
+
): Promise<string> {
|
|
6
|
+
const enc = new TextEncoder();
|
|
7
|
+
const key = await crypto.subtle.importKey(
|
|
8
|
+
'raw',
|
|
9
|
+
enc.encode(secret),
|
|
10
|
+
{ name: 'HMAC', hash: algorithm },
|
|
11
|
+
false,
|
|
12
|
+
['sign']
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const data = typeof payload === 'string' ? enc.encode(payload) : payload;
|
|
16
|
+
const signatureBuf = await crypto.subtle.sign('HMAC', key, data as any);
|
|
17
|
+
|
|
18
|
+
return Array.from(new Uint8Array(signatureBuf))
|
|
19
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
20
|
+
.join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function constantTimeEqual(a: string, b: string): boolean {
|
|
24
|
+
if (a.length !== b.length) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
let result = 0;
|
|
28
|
+
for (let i = 0; i < a.length; i++) {
|
|
29
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
30
|
+
}
|
|
31
|
+
return result === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VerifyHMACOptions {
|
|
35
|
+
payload: string | Uint8Array;
|
|
36
|
+
signature: string;
|
|
37
|
+
secret: string;
|
|
38
|
+
algorithm?: 'SHA-256' | 'SHA-1' | 'SHA-512';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function verifyHMAC(options: VerifyHMACOptions): Promise<boolean> {
|
|
42
|
+
const algorithm = options.algorithm || 'SHA-256';
|
|
43
|
+
let sig = options.signature.trim();
|
|
44
|
+
|
|
45
|
+
// Strip prefixes like sha256=, sha1=, sha512=
|
|
46
|
+
if (sig.startsWith('sha256=')) {
|
|
47
|
+
sig = sig.slice(7);
|
|
48
|
+
} else if (sig.startsWith('sha1=')) {
|
|
49
|
+
sig = sig.slice(5);
|
|
50
|
+
} else if (sig.startsWith('sha512=')) {
|
|
51
|
+
sig = sig.slice(7);
|
|
52
|
+
} else if (sig.includes('v1=')) {
|
|
53
|
+
// Stripe format: t=...,v1=...
|
|
54
|
+
const match = sig.match(/v1=([a-f0-9]+)/i);
|
|
55
|
+
if (match) {
|
|
56
|
+
sig = match[1];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const expected = await computeHMAC(options.payload, options.secret, algorithm);
|
|
61
|
+
return constantTimeEqual(sig.toLowerCase(), expected.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class WebhookVerifier {
|
|
65
|
+
constructor(private req: Request, private rawBody: string) {}
|
|
66
|
+
|
|
67
|
+
public async verifyHMAC(options: {
|
|
68
|
+
secret: string;
|
|
69
|
+
header?: string;
|
|
70
|
+
signature?: string;
|
|
71
|
+
algorithm?: 'SHA-256' | 'SHA-1' | 'SHA-512';
|
|
72
|
+
}): Promise<boolean> {
|
|
73
|
+
const algorithm = options.algorithm || 'SHA-256';
|
|
74
|
+
let signature = options.signature;
|
|
75
|
+
|
|
76
|
+
if (!signature) {
|
|
77
|
+
if (options.header) {
|
|
78
|
+
signature = this.req.headers.get(options.header) || undefined;
|
|
79
|
+
} else {
|
|
80
|
+
// Auto-detect standard webhook headers (case-insensitive in standard Request headers)
|
|
81
|
+
signature =
|
|
82
|
+
this.req.headers.get('x-hub-signature-256') ||
|
|
83
|
+
this.req.headers.get('x-signature-256') ||
|
|
84
|
+
this.req.headers.get('stripe-signature') ||
|
|
85
|
+
this.req.headers.get('x-signature') ||
|
|
86
|
+
undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!signature) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return verifyHMAC({
|
|
95
|
+
payload: this.rawBody,
|
|
96
|
+
signature,
|
|
97
|
+
secret: options.secret,
|
|
98
|
+
algorithm,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
export const MIME_TYPES: Record<string, string> = {
|
|
5
|
+
'.html': 'text/html; charset=utf-8',
|
|
6
|
+
'.htm': 'text/html; charset=utf-8',
|
|
7
|
+
'.css': 'text/css; charset=utf-8',
|
|
8
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
9
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
10
|
+
'.json': 'application/json; charset=utf-8',
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.gif': 'image/gif',
|
|
15
|
+
'.svg': 'image/svg+xml',
|
|
16
|
+
'.ico': 'image/x-icon',
|
|
17
|
+
'.woff': 'font/woff',
|
|
18
|
+
'.woff2': 'font/woff2',
|
|
19
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function getMimeType(filePath: string): string {
|
|
23
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
24
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createStaticFileResponse(filePath: string): Response | null {
|
|
28
|
+
try {
|
|
29
|
+
if (!fs.existsSync(filePath)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const stat = fs.statSync(filePath);
|
|
33
|
+
if (stat.isDirectory()) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const content = fs.readFileSync(filePath);
|
|
38
|
+
const mime = getMimeType(filePath);
|
|
39
|
+
|
|
40
|
+
return new Response(content, {
|
|
41
|
+
status: 200,
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': mime,
|
|
44
|
+
'Content-Length': String(stat.size),
|
|
45
|
+
'Cache-Control': 'public, max-age=3600',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface StoragePutOptions {
|
|
5
|
+
contentType?: string;
|
|
6
|
+
access?: 'public' | 'private';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StoragePutResult {
|
|
10
|
+
path: string;
|
|
11
|
+
url: string;
|
|
12
|
+
size: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FireStorage {
|
|
16
|
+
put(filePath: string, content: string | Uint8Array | ArrayBuffer, options?: StoragePutOptions): Promise<StoragePutResult>;
|
|
17
|
+
get(filePath: string): Promise<Uint8Array | null>;
|
|
18
|
+
getText(filePath: string): Promise<string | null>;
|
|
19
|
+
delete(filePath: string): Promise<boolean>;
|
|
20
|
+
exists(filePath: string): Promise<boolean>;
|
|
21
|
+
url(filePath: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StorageOptions {
|
|
25
|
+
driver?: 'memory' | 'local';
|
|
26
|
+
rootDir?: string;
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class MemoryStorageAdapter implements FireStorage {
|
|
31
|
+
private files: Map<string, { data: Uint8Array; contentType?: string }> = new Map();
|
|
32
|
+
private baseUrl: string;
|
|
33
|
+
|
|
34
|
+
constructor(options: { baseUrl?: string } = {}) {
|
|
35
|
+
this.baseUrl = (options.baseUrl || '/uploads').replace(/\/+$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public async put(
|
|
39
|
+
filePath: string,
|
|
40
|
+
content: string | Uint8Array | ArrayBuffer,
|
|
41
|
+
options?: StoragePutOptions
|
|
42
|
+
): Promise<StoragePutResult> {
|
|
43
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
44
|
+
let bytes: Uint8Array;
|
|
45
|
+
|
|
46
|
+
if (typeof content === 'string') {
|
|
47
|
+
bytes = new TextEncoder().encode(content);
|
|
48
|
+
} else if (content instanceof ArrayBuffer) {
|
|
49
|
+
bytes = new Uint8Array(content);
|
|
50
|
+
} else {
|
|
51
|
+
bytes = content;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.files.set(cleanPath, { data: bytes, contentType: options?.contentType });
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
path: cleanPath,
|
|
58
|
+
url: this.url(cleanPath),
|
|
59
|
+
size: bytes.byteLength,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public async get(filePath: string): Promise<Uint8Array | null> {
|
|
64
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
65
|
+
const file = this.files.get(cleanPath);
|
|
66
|
+
return file ? file.data : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public async getText(filePath: string): Promise<string | null> {
|
|
70
|
+
const bytes = await this.get(filePath);
|
|
71
|
+
return bytes ? new TextDecoder().decode(bytes) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public async delete(filePath: string): Promise<boolean> {
|
|
75
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
76
|
+
return this.files.delete(cleanPath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public async exists(filePath: string): Promise<boolean> {
|
|
80
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
81
|
+
return this.files.has(cleanPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public url(filePath: string): string {
|
|
85
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
86
|
+
return `${this.baseUrl}/${cleanPath}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class LocalDiskStorageAdapter implements FireStorage {
|
|
91
|
+
private rootDir: string;
|
|
92
|
+
private baseUrl: string;
|
|
93
|
+
|
|
94
|
+
constructor(options: { rootDir?: string; baseUrl?: string } = {}) {
|
|
95
|
+
this.rootDir = options.rootDir || path.join(process.cwd(), 'uploads');
|
|
96
|
+
this.baseUrl = (options.baseUrl || '/uploads').replace(/\/+$/, '');
|
|
97
|
+
|
|
98
|
+
if (!fs.existsSync(this.rootDir)) {
|
|
99
|
+
fs.mkdirSync(this.rootDir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private resolvePath(filePath: string): string {
|
|
104
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
105
|
+
return path.join(this.rootDir, cleanPath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async put(
|
|
109
|
+
filePath: string,
|
|
110
|
+
content: string | Uint8Array | ArrayBuffer,
|
|
111
|
+
_options?: StoragePutOptions
|
|
112
|
+
): Promise<StoragePutResult> {
|
|
113
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
114
|
+
const fullPath = this.resolvePath(cleanPath);
|
|
115
|
+
const dir = path.dirname(fullPath);
|
|
116
|
+
|
|
117
|
+
if (!fs.existsSync(dir)) {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let bytes: Uint8Array;
|
|
122
|
+
if (typeof content === 'string') {
|
|
123
|
+
bytes = new TextEncoder().encode(content);
|
|
124
|
+
} else if (content instanceof ArrayBuffer) {
|
|
125
|
+
bytes = new Uint8Array(content);
|
|
126
|
+
} else {
|
|
127
|
+
bytes = content;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fs.writeFileSync(fullPath, bytes);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
path: cleanPath,
|
|
134
|
+
url: this.url(cleanPath),
|
|
135
|
+
size: bytes.byteLength,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public async get(filePath: string): Promise<Uint8Array | null> {
|
|
140
|
+
const fullPath = this.resolvePath(filePath);
|
|
141
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
142
|
+
const buffer = fs.readFileSync(fullPath);
|
|
143
|
+
return new Uint8Array(buffer);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public async getText(filePath: string): Promise<string | null> {
|
|
147
|
+
const fullPath = this.resolvePath(filePath);
|
|
148
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
149
|
+
return fs.readFileSync(fullPath, 'utf-8');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public async delete(filePath: string): Promise<boolean> {
|
|
153
|
+
const fullPath = this.resolvePath(filePath);
|
|
154
|
+
if (!fs.existsSync(fullPath)) return false;
|
|
155
|
+
fs.unlinkSync(fullPath);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
public async exists(filePath: string): Promise<boolean> {
|
|
160
|
+
const fullPath = this.resolvePath(filePath);
|
|
161
|
+
return fs.existsSync(fullPath);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public url(filePath: string): string {
|
|
165
|
+
const cleanPath = filePath.replace(/^\/+/, '');
|
|
166
|
+
return `${this.baseUrl}/${cleanPath}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function createStorage(options: StorageOptions = {}): FireStorage {
|
|
171
|
+
const driver = options.driver || 'memory';
|
|
172
|
+
if (driver === 'local') {
|
|
173
|
+
return new LocalDiskStorageAdapter(options);
|
|
174
|
+
}
|
|
175
|
+
return new MemoryStorageAdapter(options);
|
|
176
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export interface SSEEventOptions {
|
|
2
|
+
event?: string;
|
|
3
|
+
id?: string;
|
|
4
|
+
retry?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SSEStreamWriter {
|
|
8
|
+
send(data: any, options?: SSEEventOptions): Promise<void>;
|
|
9
|
+
close(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatSSEMessage(data: any, options: SSEEventOptions = {}): string {
|
|
13
|
+
let message = '';
|
|
14
|
+
|
|
15
|
+
if (options.id !== undefined) {
|
|
16
|
+
message += `id: ${options.id}\n`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (options.event !== undefined) {
|
|
20
|
+
message += `event: ${options.event}\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (options.retry !== undefined) {
|
|
24
|
+
message += `retry: ${options.retry}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const serialized = typeof data === 'string' ? data : JSON.stringify(data);
|
|
28
|
+
const lines = serialized.split('\n');
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
message += `data: ${line}\n`;
|
|
31
|
+
}
|
|
32
|
+
message += '\n';
|
|
33
|
+
|
|
34
|
+
return message;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSSEResponse(
|
|
38
|
+
handler: (stream: SSEStreamWriter) => Promise<void> | void
|
|
39
|
+
): Response {
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
42
|
+
let isClosed = false;
|
|
43
|
+
|
|
44
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
45
|
+
start(controller) {
|
|
46
|
+
controllerRef = controller;
|
|
47
|
+
|
|
48
|
+
const writer: SSEStreamWriter = {
|
|
49
|
+
async send(data: any, options?: SSEEventOptions) {
|
|
50
|
+
if (isClosed || !controllerRef) return;
|
|
51
|
+
const formatted = formatSSEMessage(data, options);
|
|
52
|
+
controllerRef.enqueue(encoder.encode(formatted));
|
|
53
|
+
},
|
|
54
|
+
close() {
|
|
55
|
+
if (isClosed || !controllerRef) return;
|
|
56
|
+
isClosed = true;
|
|
57
|
+
try {
|
|
58
|
+
controllerRef.close();
|
|
59
|
+
} catch {}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
Promise.resolve(handler(writer)).catch((err) => {
|
|
64
|
+
if (!isClosed && controllerRef) {
|
|
65
|
+
const errMessage = formatSSEMessage({ error: err?.message || 'Stream error' }, { event: 'error' });
|
|
66
|
+
controllerRef.enqueue(encoder.encode(errMessage));
|
|
67
|
+
try {
|
|
68
|
+
controllerRef.close();
|
|
69
|
+
} catch {}
|
|
70
|
+
isClosed = true;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
cancel() {
|
|
75
|
+
isClosed = true;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const headers = new Headers();
|
|
80
|
+
headers.set('Content-Type', 'text/event-stream; charset=utf-8');
|
|
81
|
+
headers.set('Cache-Control', 'no-cache, no-transform');
|
|
82
|
+
headers.set('Connection', 'keep-alive');
|
|
83
|
+
headers.set('X-Accel-Buffering', 'no'); // Disable Nginx buffering
|
|
84
|
+
|
|
85
|
+
return new Response(stream, {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers,
|
|
88
|
+
});
|
|
89
|
+
}
|