bxo 0.0.5-dev.50 → 0.0.5-dev.52
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/REFACTOR_README.md +209 -0
- package/index.ts +4 -1554
- package/package.json +9 -1
- package/src/core/bxo.ts +437 -0
- package/src/handlers/request-handler.ts +229 -0
- package/src/index.ts +54 -0
- package/src/types/index.ts +170 -0
- package/src/utils/context-factory.ts +158 -0
- package/src/utils/helpers.ts +40 -0
- package/src/utils/index.ts +258 -0
- package/src/utils/response-handler.ts +216 -0
- package/src/utils/route-matcher.ts +191 -0
- package/tests/README.md +359 -0
- package/tests/integration/bxo.test.ts +413 -0
- package/tests/run-tests.ts +44 -0
- package/tests/unit/context-factory.test.ts +386 -0
- package/tests/unit/helpers.test.ts +253 -0
- package/tests/unit/response-handler.test.ts +301 -0
- package/tests/unit/route-matcher.test.ts +181 -0
- package/tests/unit/utils.test.ts +310 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Main BXO class
|
|
2
|
+
export { default as BXO } from './core/bxo';
|
|
3
|
+
|
|
4
|
+
// Export Zod for convenience
|
|
5
|
+
export { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
// Export helper functions
|
|
8
|
+
export { error, file, redirect, createCookieOptions } from './utils/helpers';
|
|
9
|
+
|
|
10
|
+
// Export utility functions
|
|
11
|
+
export {
|
|
12
|
+
parseQuery,
|
|
13
|
+
parseHeaders,
|
|
14
|
+
parseCookies,
|
|
15
|
+
validateData,
|
|
16
|
+
validateResponse,
|
|
17
|
+
parseRequestBody,
|
|
18
|
+
cookiesToHeaders,
|
|
19
|
+
mergeHeadersWithCookies,
|
|
20
|
+
createRedirectResponse,
|
|
21
|
+
isFileUpload,
|
|
22
|
+
getFileFromUpload,
|
|
23
|
+
getFileInfo,
|
|
24
|
+
saveUploadedFile,
|
|
25
|
+
getFileUploads,
|
|
26
|
+
getFormFields
|
|
27
|
+
} from './utils';
|
|
28
|
+
|
|
29
|
+
// Export route matching utilities
|
|
30
|
+
export { matchRoute, matchWSRoute } from './utils/route-matcher';
|
|
31
|
+
|
|
32
|
+
// Export context factory utilities
|
|
33
|
+
export { createContext, createOptionsContext, getInternalCookies } from './utils/context-factory';
|
|
34
|
+
|
|
35
|
+
// Export response handler utilities
|
|
36
|
+
export { processResponse, createErrorResponse, createValidationErrorResponse } from './utils/response-handler';
|
|
37
|
+
|
|
38
|
+
// Export types for external use
|
|
39
|
+
export type {
|
|
40
|
+
RouteConfig,
|
|
41
|
+
RouteDetail,
|
|
42
|
+
Handler,
|
|
43
|
+
WebSocketHandler,
|
|
44
|
+
WSRoute,
|
|
45
|
+
CookieOptions,
|
|
46
|
+
BXOOptions,
|
|
47
|
+
Plugin,
|
|
48
|
+
Context,
|
|
49
|
+
FileUpload,
|
|
50
|
+
FormData
|
|
51
|
+
} from './types';
|
|
52
|
+
|
|
53
|
+
// Re-export everything from the main BXO class for backward compatibility
|
|
54
|
+
export { default } from './core/bxo';
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Type utilities for extracting types from Zod schemas
|
|
4
|
+
export type InferZodType<T> = T extends z.ZodType<infer U> ? U : never;
|
|
5
|
+
|
|
6
|
+
// Response configuration types
|
|
7
|
+
export type ResponseSchema = z.ZodSchema<any>;
|
|
8
|
+
export type StatusResponseSchema = Record<number, ResponseSchema>;
|
|
9
|
+
export type ResponseConfig = ResponseSchema | StatusResponseSchema;
|
|
10
|
+
|
|
11
|
+
// Type utility to extract response type from response config
|
|
12
|
+
export type InferResponseType<T> = T extends ResponseSchema
|
|
13
|
+
? InferZodType<T>
|
|
14
|
+
: T extends StatusResponseSchema
|
|
15
|
+
? { [K in keyof T]: InferZodType<T[K]> }[number]
|
|
16
|
+
: never;
|
|
17
|
+
|
|
18
|
+
// Cookie options interface for setting cookies
|
|
19
|
+
export interface CookieOptions {
|
|
20
|
+
domain?: string;
|
|
21
|
+
path?: string;
|
|
22
|
+
expires?: Date;
|
|
23
|
+
maxAge?: number;
|
|
24
|
+
secure?: boolean;
|
|
25
|
+
httpOnly?: boolean;
|
|
26
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// OpenAPI detail information
|
|
30
|
+
export interface RouteDetail {
|
|
31
|
+
summary?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
tags?: string[];
|
|
34
|
+
operationId?: string;
|
|
35
|
+
deprecated?: boolean;
|
|
36
|
+
produces?: string[];
|
|
37
|
+
consumes?: string[];
|
|
38
|
+
[key: string]: any; // Allow additional OpenAPI properties
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Configuration interface for route handlers
|
|
42
|
+
export interface RouteConfig {
|
|
43
|
+
params?: z.ZodSchema<any>;
|
|
44
|
+
query?: z.ZodSchema<any>;
|
|
45
|
+
body?: z.ZodSchema<any>;
|
|
46
|
+
headers?: z.ZodSchema<any>;
|
|
47
|
+
cookies?: z.ZodSchema<any>;
|
|
48
|
+
response?: ResponseConfig;
|
|
49
|
+
detail?: RouteDetail;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Helper type to extract status codes from response config
|
|
53
|
+
export type StatusCodes<T> = T extends Record<number, any> ? keyof T : never;
|
|
54
|
+
|
|
55
|
+
// Context type that's fully typed based on the route configuration
|
|
56
|
+
export type Context<TConfig extends RouteConfig = {}> = {
|
|
57
|
+
params: TConfig['params'] extends z.ZodSchema<any> ? InferZodType<TConfig['params']> : Record<string, string>;
|
|
58
|
+
query: TConfig['query'] extends z.ZodSchema<any> ? InferZodType<TConfig['query']> : Record<string, string | undefined>;
|
|
59
|
+
body: TConfig['body'] extends z.ZodSchema<any> ? InferZodType<TConfig['body']> : unknown;
|
|
60
|
+
headers: TConfig['headers'] extends z.ZodSchema<any> ? InferZodType<TConfig['headers']> : Record<string, string>;
|
|
61
|
+
cookies: TConfig['cookies'] extends z.ZodSchema<any> ? InferZodType<TConfig['cookies']> : Record<string, string>;
|
|
62
|
+
path: string;
|
|
63
|
+
request: Request;
|
|
64
|
+
set: {
|
|
65
|
+
status: number;
|
|
66
|
+
headers: Record<string, string>;
|
|
67
|
+
cookies: (name: string, value: string, options?: CookieOptions) => void;
|
|
68
|
+
redirect?: { location: string; status?: number };
|
|
69
|
+
};
|
|
70
|
+
status: <T extends number>(
|
|
71
|
+
code: TConfig['response'] extends StatusResponseSchema
|
|
72
|
+
? StatusCodes<TConfig['response']> | number
|
|
73
|
+
: T,
|
|
74
|
+
data?: TConfig['response'] extends StatusResponseSchema
|
|
75
|
+
? T extends keyof TConfig['response']
|
|
76
|
+
? InferZodType<TConfig['response'][T]>
|
|
77
|
+
: any
|
|
78
|
+
: TConfig['response'] extends ResponseSchema
|
|
79
|
+
? InferZodType<TConfig['response']>
|
|
80
|
+
: any
|
|
81
|
+
) => TConfig['response'] extends StatusResponseSchema
|
|
82
|
+
? T extends keyof TConfig['response']
|
|
83
|
+
? InferZodType<TConfig['response'][T]>
|
|
84
|
+
: any
|
|
85
|
+
: TConfig['response'] extends ResponseSchema
|
|
86
|
+
? InferZodType<TConfig['response']>
|
|
87
|
+
: any;
|
|
88
|
+
redirect: (location: string, status?: number) => Response;
|
|
89
|
+
clearRedirect: () => void;
|
|
90
|
+
[key: string]: any;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Internal cookie storage interface
|
|
94
|
+
export interface InternalCookie {
|
|
95
|
+
name: string;
|
|
96
|
+
value: string;
|
|
97
|
+
domain?: string;
|
|
98
|
+
path?: string;
|
|
99
|
+
expires?: Date;
|
|
100
|
+
maxAge?: number;
|
|
101
|
+
secure?: boolean;
|
|
102
|
+
httpOnly?: boolean;
|
|
103
|
+
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Handler function type with proper response typing
|
|
107
|
+
export type Handler<TConfig extends RouteConfig = {}, EC = {}> = (
|
|
108
|
+
ctx: Context<TConfig> & EC
|
|
109
|
+
) => Promise<InferResponseType<TConfig['response']> | any> | InferResponseType<TConfig['response']> | any;
|
|
110
|
+
|
|
111
|
+
// Route definition
|
|
112
|
+
export interface Route {
|
|
113
|
+
method: string;
|
|
114
|
+
path: string;
|
|
115
|
+
handler: Handler<any>;
|
|
116
|
+
config?: RouteConfig;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// WebSocket handler interface
|
|
120
|
+
export interface WebSocketHandler {
|
|
121
|
+
onOpen?: (ws: any) => void;
|
|
122
|
+
onMessage?: (ws: any, message: string | Buffer) => void;
|
|
123
|
+
onClose?: (ws: any, code?: number, reason?: string) => void;
|
|
124
|
+
onError?: (ws: any, error: Error) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// WebSocket route definition
|
|
128
|
+
export interface WSRoute {
|
|
129
|
+
path: string;
|
|
130
|
+
handler: WebSocketHandler;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Lifecycle hooks
|
|
134
|
+
export interface LifecycleHooks {
|
|
135
|
+
onBeforeStart?: (instance: any) => Promise<void> | void;
|
|
136
|
+
onAfterStart?: (instance: any) => Promise<void> | void;
|
|
137
|
+
onBeforeStop?: (instance: any) => Promise<void> | void;
|
|
138
|
+
onAfterStop?: (instance: any) => Promise<void> | void;
|
|
139
|
+
onRequest?: (ctx: Context, instance: any) => Promise<void> | void;
|
|
140
|
+
onResponse?: (ctx: Context, response: any, instance: any) => Promise<any> | any;
|
|
141
|
+
onError?: (ctx: Context, error: Error, instance: any) => Promise<any> | any;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// BXO options interface
|
|
145
|
+
export interface BXOOptions {
|
|
146
|
+
enableValidation?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Plugin interface for middleware-style plugins
|
|
150
|
+
export interface Plugin {
|
|
151
|
+
name?: string;
|
|
152
|
+
onRequest?: (ctx: Context) => Promise<Response | void> | Response | void;
|
|
153
|
+
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
|
154
|
+
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// File upload types
|
|
158
|
+
export interface FileUpload {
|
|
159
|
+
type: 'file';
|
|
160
|
+
name: string;
|
|
161
|
+
size: number;
|
|
162
|
+
lastModified: number;
|
|
163
|
+
file: File;
|
|
164
|
+
filename: string;
|
|
165
|
+
mimetype: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface FormData {
|
|
169
|
+
[key: string]: string | FileUpload;
|
|
170
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Context, RouteConfig, InternalCookie, CookieOptions } from '../types';
|
|
2
|
+
import { validateData } from './index';
|
|
3
|
+
|
|
4
|
+
// Create a context object with validation
|
|
5
|
+
export function createContext<TConfig extends RouteConfig = {}>(
|
|
6
|
+
params: Record<string, string>,
|
|
7
|
+
query: Record<string, string | undefined>,
|
|
8
|
+
body: any,
|
|
9
|
+
headers: Record<string, string>,
|
|
10
|
+
cookies: Record<string, string>,
|
|
11
|
+
pathname: string,
|
|
12
|
+
request: Request,
|
|
13
|
+
config: RouteConfig | undefined,
|
|
14
|
+
enableValidation: boolean
|
|
15
|
+
): Context<TConfig> {
|
|
16
|
+
// Create internal cookie storage
|
|
17
|
+
const internalCookies: InternalCookie[] = [];
|
|
18
|
+
|
|
19
|
+
// Create context with validation
|
|
20
|
+
const ctx: Context<TConfig> = {
|
|
21
|
+
params: enableValidation && config?.params ? validateData(config.params, params) : params,
|
|
22
|
+
query: enableValidation && config?.query ? validateData(config.query, query) : query,
|
|
23
|
+
body: enableValidation && config?.body ? validateData(config.body, body) : body,
|
|
24
|
+
headers: enableValidation && config?.headers ? validateData(config.headers, headers) : headers,
|
|
25
|
+
cookies: enableValidation && config?.cookies ? validateData(config.cookies, cookies) : cookies,
|
|
26
|
+
path: pathname,
|
|
27
|
+
request,
|
|
28
|
+
set: {
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: {},
|
|
31
|
+
cookies: (name: string, value: string, options?: CookieOptions) => {
|
|
32
|
+
internalCookies.push({
|
|
33
|
+
name,
|
|
34
|
+
value,
|
|
35
|
+
domain: options?.domain,
|
|
36
|
+
path: options?.path,
|
|
37
|
+
expires: options?.expires,
|
|
38
|
+
maxAge: options?.maxAge,
|
|
39
|
+
secure: options?.secure,
|
|
40
|
+
httpOnly: options?.httpOnly,
|
|
41
|
+
sameSite: options?.sameSite
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
status: ((code: number, data?: any) => {
|
|
46
|
+
ctx.set.status = code;
|
|
47
|
+
return data;
|
|
48
|
+
}) as any,
|
|
49
|
+
redirect: ((location: string, status: number = 302) => {
|
|
50
|
+
// Record redirect intent only; avoid mutating generic status/headers so it can be canceled later
|
|
51
|
+
ctx.set.redirect = { location, status };
|
|
52
|
+
|
|
53
|
+
// Prepare headers for immediate Response return without persisting to ctx.set.headers
|
|
54
|
+
const responseHeaders = new Headers();
|
|
55
|
+
responseHeaders.set('Location', location);
|
|
56
|
+
|
|
57
|
+
// Add any additional headers from ctx.set.headers
|
|
58
|
+
if (ctx.set.headers) {
|
|
59
|
+
Object.entries(ctx.set.headers).forEach(([key, value]) => {
|
|
60
|
+
responseHeaders.set(key, value);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle cookies if any are set on context
|
|
65
|
+
if (internalCookies.length > 0) {
|
|
66
|
+
const cookieHeaders = internalCookies.map(cookie => {
|
|
67
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
68
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
69
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
70
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
71
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
72
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
73
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
74
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
75
|
+
return cookieString;
|
|
76
|
+
});
|
|
77
|
+
// Set multiple Set-Cookie headers properly
|
|
78
|
+
cookieHeaders.forEach(cookieHeader => {
|
|
79
|
+
responseHeaders.append('Set-Cookie', cookieHeader);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return new Response(null, {
|
|
84
|
+
status,
|
|
85
|
+
headers: responseHeaders
|
|
86
|
+
});
|
|
87
|
+
}) as any,
|
|
88
|
+
clearRedirect: (() => {
|
|
89
|
+
// Clear explicit redirect intent
|
|
90
|
+
delete ctx.set.redirect;
|
|
91
|
+
// Remove any Location header if present
|
|
92
|
+
if (ctx.set.headers) {
|
|
93
|
+
for (const key of Object.keys(ctx.set.headers)) {
|
|
94
|
+
if (key.toLowerCase() === 'location') {
|
|
95
|
+
delete ctx.set.headers[key];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Reset status if it is a redirect
|
|
100
|
+
if (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400) {
|
|
101
|
+
ctx.set.status = 200;
|
|
102
|
+
}
|
|
103
|
+
}) as any
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Store internal cookies for later use
|
|
107
|
+
(ctx as any)._internalCookies = internalCookies;
|
|
108
|
+
|
|
109
|
+
return ctx;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Get internal cookies from context
|
|
113
|
+
export function getInternalCookies(ctx: Context): InternalCookie[] {
|
|
114
|
+
return (ctx as any)._internalCookies || [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create a minimal context for OPTIONS requests
|
|
118
|
+
export function createOptionsContext(
|
|
119
|
+
pathname: string,
|
|
120
|
+
request: Request,
|
|
121
|
+
headers: Record<string, string>
|
|
122
|
+
): Context {
|
|
123
|
+
return {
|
|
124
|
+
params: {},
|
|
125
|
+
query: {},
|
|
126
|
+
body: {},
|
|
127
|
+
headers,
|
|
128
|
+
cookies: {},
|
|
129
|
+
path: pathname,
|
|
130
|
+
request,
|
|
131
|
+
set: {
|
|
132
|
+
status: 200,
|
|
133
|
+
headers: {},
|
|
134
|
+
cookies: (name: string, value: string, options?: CookieOptions) => {
|
|
135
|
+
// This is a placeholder for setting cookies.
|
|
136
|
+
// In a real Bun.serve context, you'd use Bun.serve's cookie handling.
|
|
137
|
+
// For now, we'll just log it or throw an error if not Bun.serve.
|
|
138
|
+
console.warn(`Setting cookie '${name}' with value '${value}' via ctx.set.cookies is not directly supported by Bun.serve. Use Bun.serve's cookie handling.`);
|
|
139
|
+
},
|
|
140
|
+
redirect: undefined
|
|
141
|
+
},
|
|
142
|
+
status: ((code: number, data?: any) => {
|
|
143
|
+
return data;
|
|
144
|
+
}) as any,
|
|
145
|
+
redirect: ((location: string, status: number = 302) => {
|
|
146
|
+
const responseHeaders: Record<string, string> = {
|
|
147
|
+
Location: location
|
|
148
|
+
};
|
|
149
|
+
return new Response(null, {
|
|
150
|
+
status,
|
|
151
|
+
headers: responseHeaders
|
|
152
|
+
});
|
|
153
|
+
}) as any,
|
|
154
|
+
clearRedirect: (() => {
|
|
155
|
+
// No-op for options context
|
|
156
|
+
}) as any
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CookieOptions } from '../types';
|
|
2
|
+
|
|
3
|
+
// Error helper function
|
|
4
|
+
export const error = (error: Error | string, status: number = 500) => {
|
|
5
|
+
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : error }), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'Content-Type': 'application/json' }
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// File helper function (like Elysia)
|
|
12
|
+
export const file = (path: string, options?: { type?: string; headers?: Record<string, string> }) => {
|
|
13
|
+
const bunFile = Bun.file(path);
|
|
14
|
+
|
|
15
|
+
if (options?.type || options?.headers) {
|
|
16
|
+
// Create a wrapper to override the MIME type and/or headers
|
|
17
|
+
return {
|
|
18
|
+
...bunFile,
|
|
19
|
+
type: options.type || bunFile.type,
|
|
20
|
+
headers: options.headers
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return bunFile;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Redirect helper function (like Elysia)
|
|
28
|
+
export const redirect = (location: string, status: number = 302) => {
|
|
29
|
+
return new Response(null, {
|
|
30
|
+
status,
|
|
31
|
+
headers: { Location: location }
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Helper function to create cookie options
|
|
36
|
+
export const createCookieOptions = (
|
|
37
|
+
options: CookieOptions = {}
|
|
38
|
+
): CookieOptions => ({
|
|
39
|
+
...options
|
|
40
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ResponseConfig, InternalCookie, CookieOptions } from '../types';
|
|
3
|
+
|
|
4
|
+
// Parse query string
|
|
5
|
+
export function parseQuery(searchParams: URLSearchParams): Record<string, string | undefined> {
|
|
6
|
+
const query: Record<string, string | undefined> = {};
|
|
7
|
+
searchParams.forEach((value, key) => {
|
|
8
|
+
query[key] = value;
|
|
9
|
+
});
|
|
10
|
+
return query;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Parse headers
|
|
14
|
+
export function parseHeaders(headers: Headers): Record<string, string> {
|
|
15
|
+
const headerObj: Record<string, string> = {};
|
|
16
|
+
headers.forEach((value, key) => {
|
|
17
|
+
headerObj[key] = value;
|
|
18
|
+
});
|
|
19
|
+
return headerObj;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Parse cookies from Cookie header
|
|
23
|
+
export function parseCookies(cookieHeader: string | null): Record<string, string> {
|
|
24
|
+
const cookies: Record<string, string> = {};
|
|
25
|
+
|
|
26
|
+
if (!cookieHeader) return cookies;
|
|
27
|
+
|
|
28
|
+
const cookiePairs = cookieHeader.split(';');
|
|
29
|
+
for (const pair of cookiePairs) {
|
|
30
|
+
const [name, value] = pair.trim().split('=');
|
|
31
|
+
if (name && value) {
|
|
32
|
+
cookies[decodeURIComponent(name)] = decodeURIComponent(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return cookies;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Validate data against Zod schema
|
|
40
|
+
export function validateData<T>(schema: z.ZodSchema<T> | undefined, data: any): T {
|
|
41
|
+
if (!schema) return data;
|
|
42
|
+
return schema.parse(data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate response against response config (supports both simple and status-based schemas)
|
|
46
|
+
export function validateResponse(
|
|
47
|
+
responseConfig: ResponseConfig | undefined,
|
|
48
|
+
data: any,
|
|
49
|
+
status: number = 200
|
|
50
|
+
): any {
|
|
51
|
+
if (!responseConfig) return data;
|
|
52
|
+
|
|
53
|
+
// If it's a simple schema (not status-based)
|
|
54
|
+
if ('parse' in responseConfig && typeof responseConfig.parse === 'function') {
|
|
55
|
+
return responseConfig.parse(data);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If it's a status-based schema
|
|
59
|
+
if (typeof responseConfig === 'object' && !('parse' in responseConfig)) {
|
|
60
|
+
const statusSchema = responseConfig[status];
|
|
61
|
+
if (statusSchema) {
|
|
62
|
+
return statusSchema.parse(data);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// If no specific status schema found, try to find a fallback
|
|
66
|
+
// Common fallback statuses: 200, 201, 400, 500
|
|
67
|
+
const fallbackStatuses = [200, 201, 400, 500];
|
|
68
|
+
for (const fallbackStatus of fallbackStatuses) {
|
|
69
|
+
if (responseConfig[fallbackStatus]) {
|
|
70
|
+
return responseConfig[fallbackStatus]?.parse(data);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If no schema found for the status, return data as-is
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Parse request body based on content type
|
|
82
|
+
export async function parseRequestBody(request: Request): Promise<any> {
|
|
83
|
+
const contentType = request.headers.get('content-type');
|
|
84
|
+
|
|
85
|
+
if (contentType?.includes('application/json')) {
|
|
86
|
+
try {
|
|
87
|
+
return await request.json();
|
|
88
|
+
} catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
} else if (contentType?.includes('multipart/form-data') || contentType?.includes('application/x-www-form-urlencoded')) {
|
|
92
|
+
const formData = await request.formData();
|
|
93
|
+
// Convert FormData to a structured object that preserves file information
|
|
94
|
+
const formBody: Record<string, any> = {};
|
|
95
|
+
|
|
96
|
+
for (const [key, value] of formData.entries()) {
|
|
97
|
+
if (value instanceof File) {
|
|
98
|
+
// Handle file uploads
|
|
99
|
+
formBody[key] = {
|
|
100
|
+
type: 'file',
|
|
101
|
+
name: value.name,
|
|
102
|
+
size: value.size,
|
|
103
|
+
lastModified: value.lastModified,
|
|
104
|
+
file: value, // Keep the actual File object for access
|
|
105
|
+
// Add convenience properties
|
|
106
|
+
filename: value.name,
|
|
107
|
+
mimetype: value.type || 'application/octet-stream'
|
|
108
|
+
};
|
|
109
|
+
} else {
|
|
110
|
+
// Handle regular form fields
|
|
111
|
+
formBody[key] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return formBody;
|
|
116
|
+
} else {
|
|
117
|
+
// Try to parse as JSON if it looks like JSON, otherwise treat as text
|
|
118
|
+
const textBody = await request.text();
|
|
119
|
+
try {
|
|
120
|
+
// Check if the text looks like JSON
|
|
121
|
+
if (textBody.trim().startsWith('{') || textBody.trim().startsWith('[')) {
|
|
122
|
+
return JSON.parse(textBody);
|
|
123
|
+
} else {
|
|
124
|
+
return textBody;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
return textBody;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Convert internal cookies to Set-Cookie header strings
|
|
133
|
+
export function cookiesToHeaders(cookies: InternalCookie[]): string[] {
|
|
134
|
+
return cookies.map(cookie => {
|
|
135
|
+
let cookieString = `${encodeURIComponent(cookie.name)}=${encodeURIComponent(cookie.value)}`;
|
|
136
|
+
if (cookie.domain) cookieString += `; Domain=${cookie.domain}`;
|
|
137
|
+
if (cookie.path) cookieString += `; Path=${cookie.path}`;
|
|
138
|
+
if (cookie.expires) cookieString += `; Expires=${cookie.expires.toUTCString()}`;
|
|
139
|
+
if (cookie.maxAge) cookieString += `; Max-Age=${cookie.maxAge}`;
|
|
140
|
+
if (cookie.secure) cookieString += `; Secure`;
|
|
141
|
+
if (cookie.httpOnly) cookieString += `; HttpOnly`;
|
|
142
|
+
if (cookie.sameSite) cookieString += `; SameSite=${cookie.sameSite}`;
|
|
143
|
+
return cookieString;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Merge headers with cookies
|
|
148
|
+
export function mergeHeadersWithCookies(
|
|
149
|
+
headers: Record<string, string>,
|
|
150
|
+
cookies: InternalCookie[]
|
|
151
|
+
): Headers {
|
|
152
|
+
const newHeaders = new Headers();
|
|
153
|
+
|
|
154
|
+
// Add regular headers
|
|
155
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
156
|
+
newHeaders.set(key, value);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Add Set-Cookie headers
|
|
160
|
+
const cookieHeaders = cookiesToHeaders(cookies);
|
|
161
|
+
cookieHeaders.forEach(cookieHeader => {
|
|
162
|
+
newHeaders.append('Set-Cookie', cookieHeader);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return newHeaders;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create a redirect response
|
|
169
|
+
export function createRedirectResponse(
|
|
170
|
+
location: string,
|
|
171
|
+
status: number = 302,
|
|
172
|
+
headers: Record<string, string> = {}
|
|
173
|
+
): Response {
|
|
174
|
+
const responseHeaders = new Headers();
|
|
175
|
+
responseHeaders.set('Location', location);
|
|
176
|
+
|
|
177
|
+
// Add additional headers
|
|
178
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
179
|
+
responseHeaders.set(key, value);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return new Response(null, {
|
|
183
|
+
status,
|
|
184
|
+
headers: responseHeaders
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check if a value is a file upload
|
|
189
|
+
export function isFileUpload(value: any): value is {
|
|
190
|
+
type: 'file';
|
|
191
|
+
file: File;
|
|
192
|
+
name: string;
|
|
193
|
+
size: number;
|
|
194
|
+
lastModified: number;
|
|
195
|
+
filename: string;
|
|
196
|
+
mimetype: string;
|
|
197
|
+
} {
|
|
198
|
+
return value && typeof value === 'object' && value.type === 'file' && value.file instanceof File;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Extract File object from upload value
|
|
202
|
+
export function getFileFromUpload(value: any): File | null {
|
|
203
|
+
return isFileUpload(value) ? value.file : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Get file metadata without the File object
|
|
207
|
+
export function getFileInfo(value: any): { name: string; size: number; mimetype: string; lastModified: number } | null {
|
|
208
|
+
if (isFileUpload(value)) {
|
|
209
|
+
return {
|
|
210
|
+
name: value.name,
|
|
211
|
+
size: value.size,
|
|
212
|
+
mimetype: value.mimetype,
|
|
213
|
+
lastModified: value.lastModified
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Save uploaded file to disk
|
|
220
|
+
export async function saveUploadedFile(
|
|
221
|
+
uploadValue: any,
|
|
222
|
+
destinationPath: string
|
|
223
|
+
): Promise<boolean> {
|
|
224
|
+
const file = getFileFromUpload(uploadValue);
|
|
225
|
+
if (!file) return false;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
229
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
230
|
+
await Bun.write(destinationPath, buffer);
|
|
231
|
+
return true;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error('Error saving file:', error);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Get all file uploads from form data
|
|
239
|
+
export function getFileUploads(formData: Record<string, any>): Record<string, File> {
|
|
240
|
+
const files: Record<string, File> = {};
|
|
241
|
+
for (const [key, value] of Object.entries(formData)) {
|
|
242
|
+
if (isFileUpload(value)) {
|
|
243
|
+
files[key] = value.file;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return files;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Get all non-file fields from form data
|
|
250
|
+
export function getFormFields(formData: Record<string, any>): Record<string, string> {
|
|
251
|
+
const fields: Record<string, string> = {};
|
|
252
|
+
for (const [key, value] of Object.entries(formData)) {
|
|
253
|
+
if (!isFileUpload(value)) {
|
|
254
|
+
fields[key] = String(value);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return fields;
|
|
258
|
+
}
|