@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.
Files changed (99) hide show
  1. package/README.md +276 -2
  2. package/bin/fire.js +139 -0
  3. package/bin/fire.mjs +670 -0
  4. package/bun.lock +20 -0
  5. package/docs/GETTING_STARTED.md +150 -0
  6. package/docs/LAUNCH_KIT.md +236 -0
  7. package/docs/superpowers/plans/2026-11-14-fire-api-framework.md +144 -0
  8. package/docs/superpowers/specs/fire-api-design-spec.md +111 -0
  9. package/examples/basic-api/index.ts +177 -0
  10. package/package.json +68 -4
  11. package/src/ai/mcp.ts +140 -0
  12. package/src/auth/auth.ts +110 -0
  13. package/src/auth/crypto.ts +70 -0
  14. package/src/auth/jwt.ts +105 -0
  15. package/src/cache/cache.ts +72 -0
  16. package/src/cli/bench.ts +118 -0
  17. package/src/cli/compile.ts +29 -0
  18. package/src/cli/create-app.ts +284 -0
  19. package/src/cli/docker.ts +51 -0
  20. package/src/cli/generator.ts +75 -0
  21. package/src/cli/routes.ts +76 -0
  22. package/src/client.ts +120 -0
  23. package/src/compat/express.ts +139 -0
  24. package/src/core/app.ts +723 -0
  25. package/src/core/context.ts +218 -0
  26. package/src/core/error.ts +42 -0
  27. package/src/core/router.ts +262 -0
  28. package/src/db/db-interface.ts +9 -0
  29. package/src/db/hub.ts +256 -0
  30. package/src/db/memory-adapter.ts +202 -0
  31. package/src/db/migrator.ts +100 -0
  32. package/src/db/sqlite-adapter.ts +58 -0
  33. package/src/docs/collection.ts +57 -0
  34. package/src/docs/scalar-ui.ts +25 -0
  35. package/src/events/event-bus.ts +61 -0
  36. package/src/index.ts +37 -0
  37. package/src/jobs/cron.ts +77 -0
  38. package/src/jobs/queue.ts +108 -0
  39. package/src/logging/logger.ts +66 -0
  40. package/src/metrics/metrics.ts +66 -0
  41. package/src/modules/product/product.route.ts +36 -0
  42. package/src/modules/product/product.schema.ts +10 -0
  43. package/src/schema/mock.ts +60 -0
  44. package/src/schema/openapi.ts +101 -0
  45. package/src/schema/schema.ts +519 -0
  46. package/src/schema/validator.ts +5 -0
  47. package/src/shield/compress.ts +61 -0
  48. package/src/shield/cors.ts +94 -0
  49. package/src/shield/headers.ts +55 -0
  50. package/src/shield/idempotency.ts +76 -0
  51. package/src/shield/rate-limiter.ts +81 -0
  52. package/src/shield/sanitizer.ts +23 -0
  53. package/src/shield/webhook.ts +101 -0
  54. package/src/static/serve.ts +51 -0
  55. package/src/storage/storage.ts +176 -0
  56. package/src/stream/sse.ts +89 -0
  57. package/src/studio/studio.ts +221 -0
  58. package/src/types.ts +241 -0
  59. package/src/ws/ws.ts +118 -0
  60. package/test/ai-mcp.test.ts +61 -0
  61. package/test/auth.test.ts +108 -0
  62. package/test/bench.test.ts +31 -0
  63. package/test/cache.test.ts +36 -0
  64. package/test/cli-create.test.ts +64 -0
  65. package/test/client.test.ts +54 -0
  66. package/test/collection-export.test.ts +52 -0
  67. package/test/compat.test.ts +48 -0
  68. package/test/compress.test.ts +48 -0
  69. package/test/context.test.ts +48 -0
  70. package/test/db.test.ts +62 -0
  71. package/test/docker.test.ts +28 -0
  72. package/test/events.test.ts +62 -0
  73. package/test/file-upload.test.ts +116 -0
  74. package/test/hooks.test.ts +77 -0
  75. package/test/idempotency.test.ts +65 -0
  76. package/test/integration.test.ts +111 -0
  77. package/test/jobs.test.ts +60 -0
  78. package/test/metrics.test.ts +40 -0
  79. package/test/mock.test.ts +33 -0
  80. package/test/multi-tenant.test.ts +80 -0
  81. package/test/observability.test.ts +95 -0
  82. package/test/openapi.test.ts +41 -0
  83. package/test/orm-relations.test.ts +85 -0
  84. package/test/orm-shortcuts.test.ts +52 -0
  85. package/test/rbac.test.ts +85 -0
  86. package/test/router-fastpath.test.ts +42 -0
  87. package/test/router.test.ts +84 -0
  88. package/test/schema.test.ts +49 -0
  89. package/test/seeder-migrator.test.ts +78 -0
  90. package/test/shield.test.ts +55 -0
  91. package/test/shorthand-dx.test.ts +31 -0
  92. package/test/sse.test.ts +43 -0
  93. package/test/static-serve.test.ts +53 -0
  94. package/test/storage.test.ts +31 -0
  95. package/test/studio.test.ts +43 -0
  96. package/test/tx.test.ts +92 -0
  97. package/test/webhook.test.ts +113 -0
  98. package/test/ws.test.ts +54 -0
  99. 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
+ }