@tamayrp14/fire-api 0.0.0-stage → 1.0.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 (98) hide show
  1. package/README.md +276 -2
  2. package/bin/fire.js +139 -0
  3. package/bun.lock +20 -0
  4. package/docs/GETTING_STARTED.md +150 -0
  5. package/docs/LAUNCH_KIT.md +236 -0
  6. package/docs/superpowers/plans/2026-11-14-fire-api-framework.md +144 -0
  7. package/docs/superpowers/specs/fire-api-design-spec.md +111 -0
  8. package/examples/basic-api/index.ts +177 -0
  9. package/package.json +68 -4
  10. package/src/ai/mcp.ts +140 -0
  11. package/src/auth/auth.ts +110 -0
  12. package/src/auth/crypto.ts +70 -0
  13. package/src/auth/jwt.ts +105 -0
  14. package/src/cache/cache.ts +72 -0
  15. package/src/cli/bench.ts +118 -0
  16. package/src/cli/compile.ts +29 -0
  17. package/src/cli/create-app.ts +284 -0
  18. package/src/cli/docker.ts +51 -0
  19. package/src/cli/generator.ts +75 -0
  20. package/src/cli/routes.ts +76 -0
  21. package/src/client.ts +120 -0
  22. package/src/compat/express.ts +139 -0
  23. package/src/core/app.ts +723 -0
  24. package/src/core/context.ts +218 -0
  25. package/src/core/error.ts +42 -0
  26. package/src/core/router.ts +262 -0
  27. package/src/db/db-interface.ts +9 -0
  28. package/src/db/hub.ts +256 -0
  29. package/src/db/memory-adapter.ts +202 -0
  30. package/src/db/migrator.ts +100 -0
  31. package/src/db/sqlite-adapter.ts +58 -0
  32. package/src/docs/collection.ts +57 -0
  33. package/src/docs/scalar-ui.ts +25 -0
  34. package/src/events/event-bus.ts +61 -0
  35. package/src/index.ts +37 -0
  36. package/src/jobs/cron.ts +77 -0
  37. package/src/jobs/queue.ts +108 -0
  38. package/src/logging/logger.ts +66 -0
  39. package/src/metrics/metrics.ts +66 -0
  40. package/src/modules/product/product.route.ts +36 -0
  41. package/src/modules/product/product.schema.ts +10 -0
  42. package/src/schema/mock.ts +60 -0
  43. package/src/schema/openapi.ts +101 -0
  44. package/src/schema/schema.ts +519 -0
  45. package/src/schema/validator.ts +5 -0
  46. package/src/shield/compress.ts +61 -0
  47. package/src/shield/cors.ts +94 -0
  48. package/src/shield/headers.ts +55 -0
  49. package/src/shield/idempotency.ts +76 -0
  50. package/src/shield/rate-limiter.ts +81 -0
  51. package/src/shield/sanitizer.ts +23 -0
  52. package/src/shield/webhook.ts +101 -0
  53. package/src/static/serve.ts +51 -0
  54. package/src/storage/storage.ts +176 -0
  55. package/src/stream/sse.ts +89 -0
  56. package/src/studio/studio.ts +221 -0
  57. package/src/types.ts +241 -0
  58. package/src/ws/ws.ts +118 -0
  59. package/test/ai-mcp.test.ts +61 -0
  60. package/test/auth.test.ts +108 -0
  61. package/test/bench.test.ts +31 -0
  62. package/test/cache.test.ts +36 -0
  63. package/test/cli-create.test.ts +64 -0
  64. package/test/client.test.ts +54 -0
  65. package/test/collection-export.test.ts +52 -0
  66. package/test/compat.test.ts +48 -0
  67. package/test/compress.test.ts +48 -0
  68. package/test/context.test.ts +48 -0
  69. package/test/db.test.ts +62 -0
  70. package/test/docker.test.ts +28 -0
  71. package/test/events.test.ts +62 -0
  72. package/test/file-upload.test.ts +116 -0
  73. package/test/hooks.test.ts +77 -0
  74. package/test/idempotency.test.ts +65 -0
  75. package/test/integration.test.ts +111 -0
  76. package/test/jobs.test.ts +60 -0
  77. package/test/metrics.test.ts +40 -0
  78. package/test/mock.test.ts +33 -0
  79. package/test/multi-tenant.test.ts +80 -0
  80. package/test/observability.test.ts +95 -0
  81. package/test/openapi.test.ts +41 -0
  82. package/test/orm-relations.test.ts +85 -0
  83. package/test/orm-shortcuts.test.ts +52 -0
  84. package/test/rbac.test.ts +85 -0
  85. package/test/router-fastpath.test.ts +42 -0
  86. package/test/router.test.ts +84 -0
  87. package/test/schema.test.ts +49 -0
  88. package/test/seeder-migrator.test.ts +78 -0
  89. package/test/shield.test.ts +55 -0
  90. package/test/shorthand-dx.test.ts +31 -0
  91. package/test/sse.test.ts +43 -0
  92. package/test/static-serve.test.ts +53 -0
  93. package/test/storage.test.ts +31 -0
  94. package/test/studio.test.ts +43 -0
  95. package/test/tx.test.ts +92 -0
  96. package/test/webhook.test.ts +113 -0
  97. package/test/ws.test.ts +54 -0
  98. package/tsconfig.json +19 -0
@@ -0,0 +1,519 @@
1
+ export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'enum' | 'any';
2
+
3
+ export interface ValidationError {
4
+ field: string;
5
+ message: string;
6
+ code: string;
7
+ }
8
+
9
+ export interface ValidationResult<T = any> {
10
+ success: boolean;
11
+ data?: T;
12
+ errors?: ValidationError[];
13
+ }
14
+
15
+ export abstract class BaseSchema<T = any> {
16
+ public abstract type: SchemaType;
17
+ public isOptional = false;
18
+ public defaultValue?: T;
19
+ public descriptionText?: string;
20
+
21
+ public optional(): this {
22
+ const clone = Object.create(Object.getPrototypeOf(this));
23
+ Object.assign(clone, this);
24
+ clone.isOptional = true;
25
+ return clone;
26
+ }
27
+
28
+ public default(val: T): this {
29
+ const clone = Object.create(Object.getPrototypeOf(this));
30
+ Object.assign(clone, this);
31
+ clone.defaultValue = val;
32
+ return clone;
33
+ }
34
+
35
+ public description(desc: string): this {
36
+ const clone = Object.create(Object.getPrototypeOf(this));
37
+ Object.assign(clone, this);
38
+ clone.descriptionText = desc;
39
+ return clone;
40
+ }
41
+
42
+ public abstract validate(value: any, path?: string): ValidationResult<T>;
43
+ public abstract toOpenAPI(): Record<string, any>;
44
+ }
45
+
46
+ export class StringSchema extends BaseSchema<string> {
47
+ public type: SchemaType = 'string';
48
+ public minLength?: number;
49
+ public maxLength?: number;
50
+ public isEmail = false;
51
+ public regexPattern?: RegExp;
52
+
53
+ public min(length: number): this {
54
+ this.minLength = length;
55
+ return this;
56
+ }
57
+
58
+ public max(length: number): this {
59
+ this.maxLength = length;
60
+ return this;
61
+ }
62
+
63
+ public email(): this {
64
+ this.isEmail = true;
65
+ return this;
66
+ }
67
+
68
+ public regex(pattern: RegExp): this {
69
+ this.regexPattern = pattern;
70
+ return this;
71
+ }
72
+
73
+ public validate(value: any, path = ''): ValidationResult<string> {
74
+ if (value === undefined || value === null) {
75
+ if (this.isOptional) return { success: true, data: this.defaultValue };
76
+ if (this.defaultValue !== undefined) return { success: true, data: this.defaultValue };
77
+ return {
78
+ success: false,
79
+ errors: [{ field: path || 'value', message: 'Required string', code: 'REQUIRED' }],
80
+ };
81
+ }
82
+
83
+ if (typeof value !== 'string') {
84
+ return {
85
+ success: false,
86
+ errors: [{ field: path || 'value', message: `Expected string, received ${typeof value}`, code: 'INVALID_TYPE' }],
87
+ };
88
+ }
89
+
90
+ if (this.minLength !== undefined && value.length < this.minLength) {
91
+ return {
92
+ success: false,
93
+ errors: [{ field: path || 'value', message: `Must be at least ${this.minLength} characters`, code: 'TOO_SHORT' }],
94
+ };
95
+ }
96
+
97
+ if (this.maxLength !== undefined && value.length > this.maxLength) {
98
+ return {
99
+ success: false,
100
+ errors: [{ field: path || 'value', message: `Must be at most ${this.maxLength} characters`, code: 'TOO_LONG' }],
101
+ };
102
+ }
103
+
104
+ if (this.isEmail) {
105
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
106
+ if (!emailRegex.test(value)) {
107
+ return {
108
+ success: false,
109
+ errors: [{ field: path || 'value', message: 'Invalid email address', code: 'INVALID_EMAIL' }],
110
+ };
111
+ }
112
+ }
113
+
114
+ if (this.regexPattern && !this.regexPattern.test(value)) {
115
+ return {
116
+ success: false,
117
+ errors: [{ field: path || 'value', message: 'String does not match pattern', code: 'REGEX_MISMATCH' }],
118
+ };
119
+ }
120
+
121
+ return { success: true, data: value };
122
+ }
123
+
124
+ public toOpenAPI(): Record<string, any> {
125
+ const spec: Record<string, any> = { type: 'string' };
126
+ if (this.minLength !== undefined) spec.minLength = this.minLength;
127
+ if (this.maxLength !== undefined) spec.maxLength = this.maxLength;
128
+ if (this.isEmail) spec.format = 'email';
129
+ if (this.defaultValue !== undefined) spec.default = this.defaultValue;
130
+ if (this.descriptionText) spec.description = this.descriptionText;
131
+ return spec;
132
+ }
133
+ }
134
+
135
+ export class NumberSchema extends BaseSchema<number> {
136
+ public type: SchemaType = 'number';
137
+ public minVal?: number;
138
+ public maxVal?: number;
139
+ public isInteger = false;
140
+
141
+ public min(val: number): this {
142
+ this.minVal = val;
143
+ return this;
144
+ }
145
+
146
+ public max(val: number): this {
147
+ this.maxVal = val;
148
+ return this;
149
+ }
150
+
151
+ public int(): this {
152
+ this.isInteger = true;
153
+ return this;
154
+ }
155
+
156
+ public validate(value: any, path = ''): ValidationResult<number> {
157
+ if (value === undefined || value === null) {
158
+ if (this.isOptional) return { success: true, data: this.defaultValue };
159
+ if (this.defaultValue !== undefined) return { success: true, data: this.defaultValue };
160
+ return {
161
+ success: false,
162
+ errors: [{ field: path || 'value', message: 'Required number', code: 'REQUIRED' }],
163
+ };
164
+ }
165
+
166
+ if (typeof value !== 'number' || isNaN(value)) {
167
+ return {
168
+ success: false,
169
+ errors: [{ field: path || 'value', message: `Expected number, received ${typeof value}`, code: 'INVALID_TYPE' }],
170
+ };
171
+ }
172
+
173
+ if (this.isInteger && !Number.isInteger(value)) {
174
+ return {
175
+ success: false,
176
+ errors: [{ field: path || 'value', message: 'Must be an integer', code: 'NOT_AN_INTEGER' }],
177
+ };
178
+ }
179
+
180
+ if (this.minVal !== undefined && value < this.minVal) {
181
+ return {
182
+ success: false,
183
+ errors: [{ field: path || 'value', message: `Must be >= ${this.minVal}`, code: 'TOO_SMALL' }],
184
+ };
185
+ }
186
+
187
+ if (this.maxVal !== undefined && value > this.maxVal) {
188
+ return {
189
+ success: false,
190
+ errors: [{ field: path || 'value', message: `Must be <= ${this.maxVal}`, code: 'TOO_LARGE' }],
191
+ };
192
+ }
193
+
194
+ return { success: true, data: value };
195
+ }
196
+
197
+ public toOpenAPI(): Record<string, any> {
198
+ const spec: Record<string, any> = { type: this.isInteger ? 'integer' : 'number' };
199
+ if (this.minVal !== undefined) spec.minimum = this.minVal;
200
+ if (this.maxVal !== undefined) spec.maximum = this.maxVal;
201
+ if (this.defaultValue !== undefined) spec.default = this.defaultValue;
202
+ if (this.descriptionText) spec.description = this.descriptionText;
203
+ return spec;
204
+ }
205
+ }
206
+
207
+ export class BooleanSchema extends BaseSchema<boolean> {
208
+ public type: SchemaType = 'boolean';
209
+
210
+ public validate(value: any, path = ''): ValidationResult<boolean> {
211
+ if (value === undefined || value === null) {
212
+ if (this.isOptional) return { success: true, data: this.defaultValue };
213
+ if (this.defaultValue !== undefined) return { success: true, data: this.defaultValue };
214
+ return {
215
+ success: false,
216
+ errors: [{ field: path || 'value', message: 'Required boolean', code: 'REQUIRED' }],
217
+ };
218
+ }
219
+
220
+ if (typeof value !== 'boolean') {
221
+ return {
222
+ success: false,
223
+ errors: [{ field: path || 'value', message: `Expected boolean, received ${typeof value}`, code: 'INVALID_TYPE' }],
224
+ };
225
+ }
226
+
227
+ return { success: true, data: value };
228
+ }
229
+
230
+ public toOpenAPI(): Record<string, any> {
231
+ const spec: Record<string, any> = { type: 'boolean' };
232
+ if (this.defaultValue !== undefined) spec.default = this.defaultValue;
233
+ if (this.descriptionText) spec.description = this.descriptionText;
234
+ return spec;
235
+ }
236
+ }
237
+
238
+ export class EnumSchema<T extends string> extends BaseSchema<T> {
239
+ public type: SchemaType = 'enum';
240
+ constructor(public values: T[]) {
241
+ super();
242
+ }
243
+
244
+ public validate(value: any, path = ''): ValidationResult<T> {
245
+ if (value === undefined || value === null) {
246
+ if (this.isOptional) return { success: true, data: this.defaultValue };
247
+ if (this.defaultValue !== undefined) return { success: true, data: this.defaultValue };
248
+ return {
249
+ success: false,
250
+ errors: [{ field: path || 'value', message: 'Required enum', code: 'REQUIRED' }],
251
+ };
252
+ }
253
+
254
+ if (!this.values.includes(value)) {
255
+ return {
256
+ success: false,
257
+ errors: [
258
+ {
259
+ field: path || 'value',
260
+ message: `Invalid value. Expected one of: ${this.values.join(', ')}`,
261
+ code: 'INVALID_ENUM',
262
+ },
263
+ ],
264
+ };
265
+ }
266
+
267
+ return { success: true, data: value };
268
+ }
269
+
270
+ public toOpenAPI(): Record<string, any> {
271
+ const spec: Record<string, any> = { type: 'string', enum: this.values };
272
+ if (this.defaultValue !== undefined) spec.default = this.defaultValue;
273
+ if (this.descriptionText) spec.description = this.descriptionText;
274
+ return spec;
275
+ }
276
+ }
277
+
278
+ export class ArraySchema<T = any> extends BaseSchema<T[]> {
279
+ public type: SchemaType = 'array';
280
+ public minItems?: number;
281
+ public maxItems?: number;
282
+
283
+ constructor(public itemSchema: BaseSchema<T>) {
284
+ super();
285
+ }
286
+
287
+ public min(count: number): this {
288
+ this.minItems = count;
289
+ return this;
290
+ }
291
+
292
+ public max(count: number): this {
293
+ this.maxItems = count;
294
+ return this;
295
+ }
296
+
297
+ public validate(value: any, path = ''): ValidationResult<T[]> {
298
+ if (value === undefined || value === null) {
299
+ if (this.isOptional) return { success: true, data: this.defaultValue };
300
+ if (this.defaultValue !== undefined) return { success: true, data: this.defaultValue };
301
+ return {
302
+ success: false,
303
+ errors: [{ field: path || 'value', message: 'Required array', code: 'REQUIRED' }],
304
+ };
305
+ }
306
+
307
+ if (!Array.isArray(value)) {
308
+ return {
309
+ success: false,
310
+ errors: [{ field: path || 'value', message: `Expected array, received ${typeof value}`, code: 'INVALID_TYPE' }],
311
+ };
312
+ }
313
+
314
+ if (this.minItems !== undefined && value.length < this.minItems) {
315
+ return {
316
+ success: false,
317
+ errors: [{ field: path || 'value', message: `Array must contain at least ${this.minItems} items`, code: 'TOO_FEW_ITEMS' }],
318
+ };
319
+ }
320
+
321
+ if (this.maxItems !== undefined && value.length > this.maxItems) {
322
+ return {
323
+ success: false,
324
+ errors: [{ field: path || 'value', message: `Array must contain at most ${this.maxItems} items`, code: 'TOO_MANY_ITEMS' }],
325
+ };
326
+ }
327
+
328
+ const validatedItems: T[] = [];
329
+ const allErrors: ValidationError[] = [];
330
+
331
+ for (let i = 0; i < value.length; i++) {
332
+ const itemRes = this.itemSchema.validate(value[i], `${path}[${i}]`);
333
+ if (!itemRes.success && itemRes.errors) {
334
+ allErrors.push(...itemRes.errors);
335
+ } else {
336
+ validatedItems.push(itemRes.data!);
337
+ }
338
+ }
339
+
340
+ if (allErrors.length > 0) {
341
+ return { success: false, errors: allErrors };
342
+ }
343
+
344
+ return { success: true, data: validatedItems };
345
+ }
346
+
347
+ public toOpenAPI(): Record<string, any> {
348
+ const spec: Record<string, any> = {
349
+ type: 'array',
350
+ items: this.itemSchema.toOpenAPI(),
351
+ };
352
+ if (this.minItems !== undefined) spec.minItems = this.minItems;
353
+ if (this.maxItems !== undefined) spec.maxItems = this.maxItems;
354
+ if (this.defaultValue !== undefined) spec.default = this.defaultValue;
355
+ if (this.descriptionText) spec.description = this.descriptionText;
356
+ return spec;
357
+ }
358
+ }
359
+
360
+ export class ObjectSchema<TShape extends Record<string, BaseSchema> = any> extends BaseSchema {
361
+ public type: SchemaType = 'object';
362
+
363
+ constructor(public shape: TShape) {
364
+ super();
365
+ }
366
+
367
+ public validate(value: any, path = ''): ValidationResult {
368
+ if (value === undefined || value === null) {
369
+ if (this.isOptional) return { success: true, data: this.defaultValue };
370
+ if (this.defaultValue !== undefined) return { success: true, data: this.defaultValue };
371
+ return {
372
+ success: false,
373
+ errors: [{ field: path || 'value', message: 'Required object', code: 'REQUIRED' }],
374
+ };
375
+ }
376
+
377
+ if (typeof value !== 'object' || Array.isArray(value)) {
378
+ return {
379
+ success: false,
380
+ errors: [{ field: path || 'value', message: `Expected object, received ${typeof value}`, code: 'INVALID_TYPE' }],
381
+ };
382
+ }
383
+
384
+ const result: Record<string, any> = {};
385
+ const allErrors: ValidationError[] = [];
386
+
387
+ for (const key of Object.keys(this.shape)) {
388
+ const fieldSchema = this.shape[key];
389
+ const fieldPath = path ? `${path}.${key}` : key;
390
+ const fieldRes = fieldSchema.validate(value[key], fieldPath);
391
+
392
+ if (!fieldRes.success && fieldRes.errors) {
393
+ allErrors.push(...fieldRes.errors);
394
+ } else if (fieldRes.data !== undefined) {
395
+ result[key] = fieldRes.data;
396
+ }
397
+ }
398
+
399
+ if (allErrors.length > 0) {
400
+ return { success: false, errors: allErrors };
401
+ }
402
+
403
+ return { success: true, data: result };
404
+ }
405
+
406
+ public toOpenAPI(): Record<string, any> {
407
+ const properties: Record<string, any> = {};
408
+ const required: string[] = [];
409
+
410
+ for (const [key, schema] of Object.entries(this.shape)) {
411
+ properties[key] = schema.toOpenAPI();
412
+ if (!schema.isOptional && schema.defaultValue === undefined) {
413
+ required.push(key);
414
+ }
415
+ }
416
+
417
+ const spec: Record<string, any> = {
418
+ type: 'object',
419
+ properties,
420
+ };
421
+ if (required.length > 0) spec.required = required;
422
+ if (this.descriptionText) spec.description = this.descriptionText;
423
+ return spec;
424
+ }
425
+ }
426
+
427
+ export interface FileSchemaOptions {
428
+ maxSize?: number | string; // e.g. 5242880 or '5MB'
429
+ types?: string[]; // e.g. ['image/png', 'image/jpeg']
430
+ }
431
+
432
+ export function parseSizeToBytes(size: number | string): number {
433
+ if (typeof size === 'number') return size;
434
+ const match = size.trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
435
+ if (!match) return 10 * 1024 * 1024;
436
+ const num = parseFloat(match[1]);
437
+ const unit = (match[2] || 'B').toUpperCase();
438
+ switch (unit) {
439
+ case 'KB': return num * 1024;
440
+ case 'MB': return num * 1024 * 1024;
441
+ case 'GB': return num * 1024 * 1024 * 1024;
442
+ default: return num;
443
+ }
444
+ }
445
+
446
+ export class FileSchema extends BaseSchema<any> {
447
+ public type: SchemaType = 'any';
448
+ public maxSizeBytes?: number;
449
+ public allowedTypes?: string[];
450
+
451
+ constructor(options: FileSchemaOptions = {}) {
452
+ super();
453
+ if (options.maxSize) {
454
+ this.maxSizeBytes = parseSizeToBytes(options.maxSize);
455
+ }
456
+ if (options.types) {
457
+ this.allowedTypes = options.types;
458
+ }
459
+ }
460
+
461
+ public validate(value: any, path = 'file'): ValidationResult<any> {
462
+ if (value === undefined || value === null) {
463
+ if (this.isOptional) return { success: true, data: undefined };
464
+ return { success: false, errors: [{ field: path, message: `${path} is required`, code: 'REQUIRED' }] };
465
+ }
466
+
467
+ const size = typeof value.size === 'number' ? value.size : 0;
468
+ const type = typeof value.type === 'string' ? value.type : '';
469
+
470
+ if (this.maxSizeBytes !== undefined && size > this.maxSizeBytes) {
471
+ return {
472
+ success: false,
473
+ errors: [{
474
+ field: path,
475
+ message: `File size ${size} bytes exceeds maximum allowed size of ${this.maxSizeBytes} bytes`,
476
+ code: 'FILE_TOO_LARGE',
477
+ }],
478
+ };
479
+ }
480
+
481
+ if (this.allowedTypes && this.allowedTypes.length > 0) {
482
+ const isAllowed = this.allowedTypes.some((t) =>
483
+ t.endsWith('/*') ? type.startsWith(t.slice(0, -1)) : type === t
484
+ );
485
+ if (!isAllowed) {
486
+ return {
487
+ success: false,
488
+ errors: [{
489
+ field: path,
490
+ message: `File type "${type}" is not allowed. Allowed types: ${this.allowedTypes.join(', ')}`,
491
+ code: 'FILE_INVALID_TYPE',
492
+ }],
493
+ };
494
+ }
495
+ }
496
+
497
+ return { success: true, data: value };
498
+ }
499
+
500
+ public toOpenAPI(): Record<string, any> {
501
+ return {
502
+ type: 'string',
503
+ format: 'binary',
504
+ description: this.descriptionText || 'Binary file upload',
505
+ };
506
+ }
507
+ }
508
+
509
+ export const s = {
510
+ string: () => new StringSchema(),
511
+ number: () => new NumberSchema(),
512
+ boolean: () => new BooleanSchema(),
513
+ enum: <T extends string>(values: T[]) => new EnumSchema<T>(values),
514
+ array: <T>(itemSchema: BaseSchema<T>) => new ArraySchema<T>(itemSchema),
515
+ object: <TShape extends Record<string, BaseSchema>>(shape: TShape) => new ObjectSchema<TShape>(shape),
516
+ file: (options?: FileSchemaOptions) => new FileSchema(options),
517
+ };
518
+
519
+ export type Infer<T> = T extends BaseSchema<infer R> ? R : any;
@@ -0,0 +1,5 @@
1
+ import { BaseSchema, ValidationResult } from './schema';
2
+
3
+ export function validate<T>(schema: BaseSchema<T>, value: any): ValidationResult<T> {
4
+ return schema.validate(value);
5
+ }
@@ -0,0 +1,61 @@
1
+ export interface CompressionOptions {
2
+ enabled?: boolean;
3
+ threshold?: number; // In bytes (default 1024)
4
+ }
5
+
6
+ export async function compressResponse(
7
+ response: Response,
8
+ request: Request,
9
+ options: CompressionOptions = {}
10
+ ): Promise<Response> {
11
+ if (options.enabled === false) return response;
12
+
13
+ const acceptEncoding = request.headers.get('accept-encoding') || '';
14
+ if (!acceptEncoding.includes('gzip')) return response;
15
+
16
+ // Don't re-compress if already compressed or SSE stream
17
+ const existingEncoding = response.headers.get('content-encoding');
18
+ if (existingEncoding) return response;
19
+
20
+ const contentType = response.headers.get('content-type') || '';
21
+ if (contentType.includes('text/event-stream')) return response;
22
+
23
+ // Only compress compressible text/json types
24
+ const isCompressible =
25
+ contentType.includes('json') ||
26
+ contentType.includes('text') ||
27
+ contentType.includes('javascript') ||
28
+ contentType.includes('xml') ||
29
+ contentType.includes('html');
30
+
31
+ if (!isCompressible && contentType !== '') return response;
32
+
33
+ const threshold = options.threshold ?? 1024;
34
+
35
+ // Read body buffer to check size
36
+ const arrayBuffer = await response.arrayBuffer();
37
+ if (arrayBuffer.byteLength < threshold) {
38
+ // Return original uncompressed response with restored body
39
+ return new Response(arrayBuffer, {
40
+ status: response.status,
41
+ statusText: response.statusText,
42
+ headers: response.headers,
43
+ });
44
+ }
45
+
46
+ // Create stream compression using native Web Standard CompressionStream
47
+ const stream = new Response(arrayBuffer).body!.pipeThrough(new CompressionStream('gzip'));
48
+
49
+ const newHeaders = new Headers(response.headers);
50
+ newHeaders.set('Content-Encoding', 'gzip');
51
+ newHeaders.delete('Content-Length'); // Content length changes after compression
52
+
53
+ const vary = newHeaders.get('Vary');
54
+ newHeaders.set('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');
55
+
56
+ return new Response(stream, {
57
+ status: response.status,
58
+ statusText: response.statusText,
59
+ headers: newHeaders,
60
+ });
61
+ }
@@ -0,0 +1,94 @@
1
+ export interface CorsOptions {
2
+ origin?: string | string[] | ((origin: string) => boolean);
3
+ methods?: string[];
4
+ allowedHeaders?: string[];
5
+ exposedHeaders?: string[];
6
+ credentials?: boolean;
7
+ maxAge?: number;
8
+ }
9
+
10
+ export function handleCORS(request: Request, options: CorsOptions = {}): Response | null {
11
+ const origin = request.headers.get('origin');
12
+ if (!origin) return null; // Not a CORS request
13
+
14
+ const isPreflight = request.method === 'OPTIONS';
15
+ const allowedOrigin = resolveAllowedOrigin(origin, options.origin);
16
+
17
+ if (!allowedOrigin) {
18
+ if (isPreflight) {
19
+ return new Response(null, { status: 403 });
20
+ }
21
+ return null;
22
+ }
23
+
24
+ const headers = new Headers();
25
+ headers.set('Access-Control-Allow-Origin', allowedOrigin);
26
+
27
+ if (options.credentials) {
28
+ headers.set('Access-Control-Allow-Credentials', 'true');
29
+ }
30
+
31
+ if (options.exposedHeaders && options.exposedHeaders.length > 0) {
32
+ headers.set('Access-Control-Expose-Headers', options.exposedHeaders.join(', '));
33
+ }
34
+
35
+ if (isPreflight) {
36
+ const methods = options.methods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'];
37
+ headers.set('Access-Control-Allow-Methods', methods.join(', '));
38
+
39
+ const reqHeaders = request.headers.get('access-control-request-headers');
40
+ const allowedHeaders = options.allowedHeaders
41
+ ? options.allowedHeaders.join(', ')
42
+ : reqHeaders || 'Content-Type, Authorization';
43
+ headers.set('Access-Control-Allow-Headers', allowedHeaders);
44
+
45
+ const maxAge = options.maxAge ?? 86400; // 24 hours
46
+ headers.set('Access-Control-Max-Age', maxAge.toString());
47
+
48
+ return new Response(null, { status: 204, headers });
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ export function appendCorsHeaders(response: Response, request: Request, options: CorsOptions = {}): Response {
55
+ const origin = request.headers.get('origin');
56
+ if (!origin) return response;
57
+
58
+ const allowedOrigin = resolveAllowedOrigin(origin, options.origin);
59
+ if (!allowedOrigin) return response;
60
+
61
+ const headers = new Headers(response.headers);
62
+ headers.set('Access-Control-Allow-Origin', allowedOrigin);
63
+ if (options.credentials) {
64
+ headers.set('Access-Control-Allow-Credentials', 'true');
65
+ }
66
+ if (options.exposedHeaders && options.exposedHeaders.length > 0) {
67
+ headers.set('Access-Control-Expose-Headers', options.exposedHeaders.join(', '));
68
+ }
69
+
70
+ return new Response(response.body, {
71
+ status: response.status,
72
+ statusText: response.statusText,
73
+ headers,
74
+ });
75
+ }
76
+
77
+ function resolveAllowedOrigin(
78
+ reqOrigin: string,
79
+ configuredOrigin?: string | string[] | ((origin: string) => boolean)
80
+ ): string | null {
81
+ if (!configuredOrigin || configuredOrigin === '*') {
82
+ return '*';
83
+ }
84
+ if (typeof configuredOrigin === 'string') {
85
+ return configuredOrigin === reqOrigin ? reqOrigin : null;
86
+ }
87
+ if (Array.isArray(configuredOrigin)) {
88
+ return configuredOrigin.includes(reqOrigin) ? reqOrigin : null;
89
+ }
90
+ if (typeof configuredOrigin === 'function') {
91
+ return configuredOrigin(reqOrigin) ? reqOrigin : null;
92
+ }
93
+ return null;
94
+ }