bxo 0.0.5-dev.51 → 0.0.5-dev.53
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/index.ts +4 -1549
- package/package.json +9 -1
- package/src/core/bxo.ts +438 -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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { Context, InternalCookie } from '../types';
|
|
2
|
+
import { validateResponse, mergeHeadersWithCookies } from './index';
|
|
3
|
+
|
|
4
|
+
// Process and format the response from a route handler
|
|
5
|
+
export function processResponse(
|
|
6
|
+
response: any,
|
|
7
|
+
ctx: Context,
|
|
8
|
+
internalCookies: InternalCookie[],
|
|
9
|
+
enableValidation: boolean,
|
|
10
|
+
routeConfig?: any
|
|
11
|
+
): Response {
|
|
12
|
+
// If the handler did not return a response, but a redirect was configured via ctx.set,
|
|
13
|
+
// automatically create a redirect Response so users can call ctx.redirect(...) or set headers without returning.
|
|
14
|
+
const hasImplicitRedirectIntent = !!ctx.set.redirect
|
|
15
|
+
|| (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400);
|
|
16
|
+
|
|
17
|
+
if ((response === undefined || response === null) && hasImplicitRedirectIntent) {
|
|
18
|
+
const locationFromHeaders = ctx.set.headers && Object.entries(ctx.set.headers).find(([k]) => k.toLowerCase() === 'location')?.[1];
|
|
19
|
+
const location = ctx.set.redirect?.location || locationFromHeaders;
|
|
20
|
+
if (location) {
|
|
21
|
+
// Build headers, ensuring Location is present
|
|
22
|
+
let responseHeaders: Record<string, string> = ctx.set.headers ? { ...ctx.set.headers } : {};
|
|
23
|
+
if (!Object.keys(responseHeaders).some(k => k.toLowerCase() === 'location')) {
|
|
24
|
+
responseHeaders['Location'] = location;
|
|
25
|
+
}
|
|
26
|
+
// Determine status precedence: redirect.status > set.status > 302
|
|
27
|
+
const status = ctx.set.redirect?.status ?? ctx.set.status ?? 302;
|
|
28
|
+
|
|
29
|
+
// Handle cookies if any are set
|
|
30
|
+
if (internalCookies.length > 0) {
|
|
31
|
+
const headers = mergeHeadersWithCookies(responseHeaders, internalCookies);
|
|
32
|
+
const finalHeaders: Record<string, string> = {};
|
|
33
|
+
headers.forEach((value, key) => {
|
|
34
|
+
finalHeaders[key] = value;
|
|
35
|
+
});
|
|
36
|
+
responseHeaders = finalHeaders;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Response(null, {
|
|
40
|
+
status,
|
|
41
|
+
headers: responseHeaders
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate response against schema if provided
|
|
47
|
+
if (enableValidation && routeConfig?.response && !(response instanceof Response)) {
|
|
48
|
+
try {
|
|
49
|
+
const status = ctx.set.status || 200;
|
|
50
|
+
response = validateResponse(routeConfig.response, response, status);
|
|
51
|
+
} catch (validationError) {
|
|
52
|
+
// Response validation failed
|
|
53
|
+
let validationDetails = undefined;
|
|
54
|
+
if (validationError instanceof Error) {
|
|
55
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
56
|
+
validationDetails = validationError.errors;
|
|
57
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
|
58
|
+
validationDetails = validationError.issues;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
|
63
|
+
? `Validation failed for ${validationDetails.length} field(s)`
|
|
64
|
+
: 'Validation failed';
|
|
65
|
+
|
|
66
|
+
return new Response(JSON.stringify({
|
|
67
|
+
error: errorMessage,
|
|
68
|
+
details: validationDetails
|
|
69
|
+
}), {
|
|
70
|
+
status: 500,
|
|
71
|
+
headers: { 'Content-Type': 'application/json' }
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Convert response to Response object
|
|
77
|
+
if (response instanceof Response) {
|
|
78
|
+
// If there are headers set via ctx.set.headers, merge them with the Response headers
|
|
79
|
+
if (ctx.set.headers && Object.keys(ctx.set.headers).length > 0) {
|
|
80
|
+
const newHeaders = mergeHeadersWithCookies(ctx.set.headers, internalCookies);
|
|
81
|
+
|
|
82
|
+
// Create new Response with merged headers
|
|
83
|
+
return new Response(response.body, {
|
|
84
|
+
status: ctx.set.status || response.status,
|
|
85
|
+
statusText: response.statusText,
|
|
86
|
+
headers: newHeaders
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return response;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Handle File response (like Elysia)
|
|
94
|
+
if (response instanceof File || (typeof Bun !== 'undefined' && response instanceof Bun.file('').constructor)) {
|
|
95
|
+
const file = response as File;
|
|
96
|
+
const responseInit: ResponseInit = {
|
|
97
|
+
status: ctx.set.status || 200,
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
100
|
+
'Content-Length': file.size.toString(),
|
|
101
|
+
...ctx.set.headers
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return new Response(file, responseInit);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle Bun.file() response
|
|
108
|
+
if (typeof response === 'object' && response && 'stream' in response && 'size' in response) {
|
|
109
|
+
const bunFile = response as any;
|
|
110
|
+
const responseInit: ResponseInit = {
|
|
111
|
+
status: ctx.set.status || 200,
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': bunFile.type || 'application/octet-stream',
|
|
114
|
+
'Content-Length': bunFile.size?.toString() || '',
|
|
115
|
+
...ctx.set.headers,
|
|
116
|
+
...(bunFile.headers || {}) // Support custom headers from file helper
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return new Response(bunFile, responseInit);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Prepare headers with cookies
|
|
123
|
+
let responseHeaders = ctx.set.headers ? { ...ctx.set.headers } : {};
|
|
124
|
+
|
|
125
|
+
// Handle cookies if any are set
|
|
126
|
+
if (internalCookies.length > 0) {
|
|
127
|
+
const headers = mergeHeadersWithCookies(responseHeaders, internalCookies);
|
|
128
|
+
|
|
129
|
+
if (typeof response === 'string') {
|
|
130
|
+
headers.set('Content-Type', 'text/plain');
|
|
131
|
+
return new Response(response, {
|
|
132
|
+
status: ctx.set.status || 200,
|
|
133
|
+
headers: headers
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
headers.set('Content-Type', 'application/json');
|
|
138
|
+
return new Response(JSON.stringify(response), {
|
|
139
|
+
status: ctx.set.status || 200,
|
|
140
|
+
headers: headers
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// If no cookies, use the original responseHeaders
|
|
145
|
+
const responseInit: ResponseInit = {
|
|
146
|
+
status: ctx.set.status || 200,
|
|
147
|
+
headers: responseHeaders
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (typeof response === 'string') {
|
|
151
|
+
return new Response(response, {
|
|
152
|
+
...responseInit,
|
|
153
|
+
headers: {
|
|
154
|
+
'Content-Type': 'text/plain',
|
|
155
|
+
...responseInit.headers
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return new Response(JSON.stringify(response), {
|
|
161
|
+
...responseInit,
|
|
162
|
+
headers: {
|
|
163
|
+
'Content-Type': 'application/json',
|
|
164
|
+
...responseInit.headers
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Create error response
|
|
170
|
+
export function createErrorResponse(
|
|
171
|
+
error: Error | string,
|
|
172
|
+
status: number = 500,
|
|
173
|
+
details?: any
|
|
174
|
+
): Response {
|
|
175
|
+
const errorMessage = error instanceof Error ? error.message : error;
|
|
176
|
+
const response = {
|
|
177
|
+
error: errorMessage,
|
|
178
|
+
...(details && { details })
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return new Response(JSON.stringify(response), {
|
|
182
|
+
status,
|
|
183
|
+
headers: { 'Content-Type': 'application/json' }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create validation error response
|
|
188
|
+
export function createValidationErrorResponse(
|
|
189
|
+
validationError: any,
|
|
190
|
+
status: number = 400
|
|
191
|
+
): Response {
|
|
192
|
+
let validationDetails = undefined;
|
|
193
|
+
|
|
194
|
+
// Handle Error objects
|
|
195
|
+
if (validationError instanceof Error) {
|
|
196
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
197
|
+
validationDetails = validationError.errors;
|
|
198
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
|
199
|
+
validationDetails = validationError.issues;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Handle plain objects with errors or issues properties
|
|
203
|
+
else if (validationError && typeof validationError === 'object') {
|
|
204
|
+
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
205
|
+
validationDetails = validationError.errors;
|
|
206
|
+
} else if ('issues' in validationError && Array.isArray(validationError.issues)) {
|
|
207
|
+
validationDetails = validationError.issues;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const errorMessage = validationDetails && validationDetails.length > 0
|
|
212
|
+
? `Validation failed for ${validationDetails.length} field(s)`
|
|
213
|
+
: 'Validation failed';
|
|
214
|
+
|
|
215
|
+
return createErrorResponse(errorMessage, status, validationDetails);
|
|
216
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { Route, WSRoute } from '../types';
|
|
2
|
+
|
|
3
|
+
// Route matching utility for HTTP routes
|
|
4
|
+
export function matchRoute(
|
|
5
|
+
method: string,
|
|
6
|
+
pathname: string,
|
|
7
|
+
routes: Route[]
|
|
8
|
+
): { route: Route; params: Record<string, string> } | null {
|
|
9
|
+
for (const route of routes) {
|
|
10
|
+
if (route.method !== method) continue;
|
|
11
|
+
|
|
12
|
+
const routeSegments = route.path.split('/').filter(Boolean);
|
|
13
|
+
const pathSegments = pathname.split('/').filter(Boolean);
|
|
14
|
+
|
|
15
|
+
const params: Record<string, string> = {};
|
|
16
|
+
let isMatch = true;
|
|
17
|
+
|
|
18
|
+
// Check for wildcards in the route
|
|
19
|
+
const hasWildcards = routeSegments.some(segment => segment === '*' || segment === '**');
|
|
20
|
+
|
|
21
|
+
// Handle routes with wildcards
|
|
22
|
+
if (hasWildcards) {
|
|
23
|
+
isMatch = matchRouteWithWildcards(routeSegments, pathSegments, params);
|
|
24
|
+
} else {
|
|
25
|
+
// Exact matching for routes without wildcards
|
|
26
|
+
if (routeSegments.length !== pathSegments.length) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
31
|
+
const routeSegment = routeSegments[i];
|
|
32
|
+
const pathSegment = pathSegments[i];
|
|
33
|
+
|
|
34
|
+
if (routeSegment.startsWith(':')) {
|
|
35
|
+
const paramName = routeSegment.slice(1);
|
|
36
|
+
params[paramName] = pathSegment;
|
|
37
|
+
} else if (routeSegment !== pathSegment) {
|
|
38
|
+
isMatch = false;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isMatch) {
|
|
45
|
+
return { route, params };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Helper function to match routes with wildcards
|
|
53
|
+
function matchRouteWithWildcards(
|
|
54
|
+
routeSegments: string[],
|
|
55
|
+
pathSegments: string[],
|
|
56
|
+
params: Record<string, string>
|
|
57
|
+
): boolean {
|
|
58
|
+
let routeIndex = 0;
|
|
59
|
+
let pathIndex = 0;
|
|
60
|
+
let wildcardCount = 0;
|
|
61
|
+
|
|
62
|
+
while (routeIndex < routeSegments.length && pathIndex < pathSegments.length) {
|
|
63
|
+
const routeSegment = routeSegments[routeIndex];
|
|
64
|
+
const pathSegment = pathSegments[pathIndex];
|
|
65
|
+
|
|
66
|
+
if (routeSegment === '**') {
|
|
67
|
+
// Double wildcard - find the next non-wildcard segment
|
|
68
|
+
let nextNonWildcardIndex = routeIndex + 1;
|
|
69
|
+
while (nextNonWildcardIndex < routeSegments.length &&
|
|
70
|
+
(routeSegments[nextNonWildcardIndex] === '*' || routeSegments[nextNonWildcardIndex] === '**')) {
|
|
71
|
+
nextNonWildcardIndex++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (nextNonWildcardIndex >= routeSegments.length) {
|
|
75
|
+
// Double wildcard at the end - capture everything remaining
|
|
76
|
+
const remainingPath = pathSegments.slice(pathIndex).join('/');
|
|
77
|
+
params[`**${wildcardCount}`] = remainingPath;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Find the next matching segment in the path
|
|
82
|
+
const nextRouteSegment = routeSegments[nextNonWildcardIndex];
|
|
83
|
+
let foundMatch = false;
|
|
84
|
+
|
|
85
|
+
for (let j = pathIndex; j < pathSegments.length; j++) {
|
|
86
|
+
const currentPathSegment = pathSegments[j];
|
|
87
|
+
|
|
88
|
+
if (nextRouteSegment.startsWith(':')) {
|
|
89
|
+
// Param segment - always matches
|
|
90
|
+
const matchedPath = pathSegments.slice(pathIndex, j).join('/');
|
|
91
|
+
params[`**${wildcardCount}`] = matchedPath;
|
|
92
|
+
pathIndex = j;
|
|
93
|
+
routeIndex = nextNonWildcardIndex;
|
|
94
|
+
foundMatch = true;
|
|
95
|
+
break;
|
|
96
|
+
} else if (nextRouteSegment === '*') {
|
|
97
|
+
// Single wildcard - always matches
|
|
98
|
+
const matchedPath = pathSegments.slice(pathIndex, j).join('/');
|
|
99
|
+
params[`**${wildcardCount}`] = matchedPath;
|
|
100
|
+
pathIndex = j;
|
|
101
|
+
routeIndex = nextNonWildcardIndex;
|
|
102
|
+
foundMatch = true;
|
|
103
|
+
break;
|
|
104
|
+
} else if (nextRouteSegment === currentPathSegment) {
|
|
105
|
+
// Exact match
|
|
106
|
+
const matchedPath = pathSegments.slice(pathIndex, j).join('/');
|
|
107
|
+
params[`**${wildcardCount}`] = matchedPath;
|
|
108
|
+
pathIndex = j;
|
|
109
|
+
routeIndex = nextNonWildcardIndex;
|
|
110
|
+
foundMatch = true;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!foundMatch) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
wildcardCount++;
|
|
120
|
+
continue;
|
|
121
|
+
} else if (routeSegment === '*') {
|
|
122
|
+
// Single wildcard - capture one segment
|
|
123
|
+
params[`*${wildcardCount}`] = pathSegment;
|
|
124
|
+
wildcardCount++;
|
|
125
|
+
routeIndex++;
|
|
126
|
+
pathIndex++;
|
|
127
|
+
} else if (routeSegment.startsWith(':')) {
|
|
128
|
+
// Parameter segment
|
|
129
|
+
const paramName = routeSegment.slice(1);
|
|
130
|
+
params[paramName] = pathSegment;
|
|
131
|
+
routeIndex++;
|
|
132
|
+
pathIndex++;
|
|
133
|
+
} else if (routeSegment === pathSegment) {
|
|
134
|
+
// Exact match
|
|
135
|
+
routeIndex++;
|
|
136
|
+
pathIndex++;
|
|
137
|
+
} else {
|
|
138
|
+
// No match
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if we've processed all route segments
|
|
144
|
+
return routeIndex >= routeSegments.length && pathIndex >= pathSegments.length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// WebSocket route matching utility
|
|
148
|
+
export function matchWSRoute(
|
|
149
|
+
pathname: string,
|
|
150
|
+
routes: WSRoute[]
|
|
151
|
+
): { route: WSRoute; params: Record<string, string> } | null {
|
|
152
|
+
for (const route of routes) {
|
|
153
|
+
const routeSegments = route.path.split('/').filter(Boolean);
|
|
154
|
+
const pathSegments = pathname.split('/').filter(Boolean);
|
|
155
|
+
|
|
156
|
+
const params: Record<string, string> = {};
|
|
157
|
+
let isMatch = true;
|
|
158
|
+
|
|
159
|
+
// Check for wildcards in the route
|
|
160
|
+
const hasWildcards = routeSegments.some(segment => segment === '*' || segment === '**');
|
|
161
|
+
|
|
162
|
+
// Handle routes with wildcards
|
|
163
|
+
if (hasWildcards) {
|
|
164
|
+
isMatch = matchRouteWithWildcards(routeSegments, pathSegments, params);
|
|
165
|
+
} else {
|
|
166
|
+
// Exact matching for routes without wildcards
|
|
167
|
+
if (routeSegments.length !== pathSegments.length) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
172
|
+
const routeSegment = routeSegments[i];
|
|
173
|
+
const pathSegment = pathSegments[i];
|
|
174
|
+
|
|
175
|
+
if (routeSegment.startsWith(':')) {
|
|
176
|
+
const paramName = routeSegment.slice(1);
|
|
177
|
+
params[paramName] = pathSegment;
|
|
178
|
+
} else if (routeSegment !== pathSegment) {
|
|
179
|
+
isMatch = false;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isMatch) {
|
|
186
|
+
return { route, params };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return null;
|
|
191
|
+
}
|