bxo 0.0.5-dev.63 → 0.0.5-dev.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/examples/serve-react/README.md +15 -0
- package/examples/serve-react/app.tsx +8 -0
- package/examples/serve-react/bun.lock +42 -0
- package/examples/serve-react/index.html +9 -0
- package/examples/serve-react/index.ts +27 -0
- package/examples/serve-react/package.json +17 -0
- package/examples/serve-react/tsconfig.json +29 -0
- package/package.json +1 -1
- package/src/core/bxo.ts +34 -14
- package/src/handlers/request-handler.ts +2 -1
- package/src/types/index.ts +1 -0
- package/src/utils/index.ts +73 -2
- package/src/utils/response-handler.ts +14 -18
- package/tests/integration/bxo.test.ts +18 -0
- package/tests/unit/response-handler.test.ts +26 -0
- package/tests/unit/utils.test.ts +42 -0
package/README.md
CHANGED
|
@@ -406,6 +406,76 @@ await app.stop();
|
|
|
406
406
|
await app.listen(3000); // Same as app.start(3000)
|
|
407
407
|
```
|
|
408
408
|
|
|
409
|
+
### Custom Serve Options
|
|
410
|
+
|
|
411
|
+
BXO allows you to extend the native Bun serve options, giving you full control over the server configuration while maintaining the framework's functionality:
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
// Basic serve options
|
|
415
|
+
const app = new BXO({
|
|
416
|
+
serve: {
|
|
417
|
+
port: 3000,
|
|
418
|
+
hostname: "0.0.0.0", // Listen on all interfaces
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// With TLS/HTTPS options
|
|
423
|
+
const app = new BXO({
|
|
424
|
+
serve: {
|
|
425
|
+
port: 443,
|
|
426
|
+
hostname: "localhost",
|
|
427
|
+
// tls: {
|
|
428
|
+
// cert: Bun.file("cert.pem"),
|
|
429
|
+
// key: Bun.file("key.pem"),
|
|
430
|
+
// }
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// With development options
|
|
435
|
+
const app = new BXO({
|
|
436
|
+
serve: {
|
|
437
|
+
port: 5000,
|
|
438
|
+
development: true, // Enable development mode
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// With custom error handling
|
|
443
|
+
const app = new BXO({
|
|
444
|
+
serve: {
|
|
445
|
+
port: 6000,
|
|
446
|
+
error: (error) => {
|
|
447
|
+
console.error("Custom error handler:", error);
|
|
448
|
+
return new Response("Something went wrong", { status: 500 });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// With custom fetch handler (extends framework functionality)
|
|
454
|
+
const app = new BXO({
|
|
455
|
+
serve: {
|
|
456
|
+
port: 4000,
|
|
457
|
+
// You can override the fetch handler if needed
|
|
458
|
+
// fetch: (request, server) => {
|
|
459
|
+
// // Your custom logic here
|
|
460
|
+
// // You can still call the framework's handler
|
|
461
|
+
// return app.requestHandler.handleRequest(request, server);
|
|
462
|
+
// }
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Available Serve Options:**
|
|
468
|
+
- `port` - Server port (default: 3000)
|
|
469
|
+
- `hostname` - Server hostname (default: 'localhost')
|
|
470
|
+
- `development` - Enable development mode
|
|
471
|
+
- `tls` - TLS/HTTPS configuration
|
|
472
|
+
- `error` - Custom error handler
|
|
473
|
+
- `fetch` - Custom fetch handler (overrides framework's handler)
|
|
474
|
+
- `websocket` - WebSocket configuration
|
|
475
|
+
- And all other [Bun.serve options](https://bun.sh/docs/api/http#bun-serve)
|
|
476
|
+
|
|
477
|
+
The framework will merge your serve options with its own defaults, ensuring that BXO's routing, middleware, and WebSocket functionality continue to work while allowing you to customize the server behavior.
|
|
478
|
+
|
|
409
479
|
### Development vs Production
|
|
410
480
|
|
|
411
481
|
```typescript
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# serve-react
|
|
2
|
+
|
|
3
|
+
To install dependencies:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun install
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
To run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun run index.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This project was created using `bun init` in bun v1.2.19. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"workspaces": {
|
|
4
|
+
"": {
|
|
5
|
+
"name": "serve-react",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@types/react-dom": "^19.1.9",
|
|
8
|
+
"react": "^19.1.1",
|
|
9
|
+
"react-dom": "^19.1.1",
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/bun": "latest",
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"typescript": "^5",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
"packages": {
|
|
20
|
+
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
|
|
21
|
+
|
|
22
|
+
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
|
23
|
+
|
|
24
|
+
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
|
25
|
+
|
|
26
|
+
"@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
|
|
27
|
+
|
|
28
|
+
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
|
|
29
|
+
|
|
30
|
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
|
31
|
+
|
|
32
|
+
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
|
33
|
+
|
|
34
|
+
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
|
|
35
|
+
|
|
36
|
+
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
|
37
|
+
|
|
38
|
+
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
|
39
|
+
|
|
40
|
+
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import BXO from "../../src";
|
|
2
|
+
import index from "./index.html"
|
|
3
|
+
|
|
4
|
+
// Create BXO instance with custom serve options
|
|
5
|
+
const app = new BXO({
|
|
6
|
+
serve: {
|
|
7
|
+
port: 4000,
|
|
8
|
+
routes: {
|
|
9
|
+
"/*": index
|
|
10
|
+
}
|
|
11
|
+
// You can add any Bun.serve options here
|
|
12
|
+
// For example, you can add custom headers, TLS options, etc.
|
|
13
|
+
// The framework will merge these with its own defaults
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Add routes
|
|
18
|
+
app.get("/", () => {
|
|
19
|
+
return index
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.get("/api/status", () => {
|
|
23
|
+
return { status: "ok", message: "BXO is running with custom serve options" };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Start the server
|
|
27
|
+
app.start();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "serve-react",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"@types/bun": "latest"
|
|
8
|
+
},
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"typescript": "^5"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@types/react-dom": "^19.1.9",
|
|
14
|
+
"react": "^19.1.1",
|
|
15
|
+
"react-dom": "^19.1.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext", "DOM"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|
package/package.json
CHANGED
package/src/core/bxo.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Route,
|
|
3
|
-
WSRoute,
|
|
4
|
-
Handler,
|
|
5
|
-
WebSocketHandler,
|
|
6
|
-
RouteConfig,
|
|
7
|
-
LifecycleHooks,
|
|
8
|
-
Plugin,
|
|
9
|
-
BXOOptions
|
|
1
|
+
import type {
|
|
2
|
+
Route,
|
|
3
|
+
WSRoute,
|
|
4
|
+
Handler,
|
|
5
|
+
WebSocketHandler,
|
|
6
|
+
RouteConfig,
|
|
7
|
+
LifecycleHooks,
|
|
8
|
+
Plugin,
|
|
9
|
+
BXOOptions
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { RequestHandler } from '../handlers/request-handler';
|
|
12
12
|
|
|
@@ -22,9 +22,11 @@ export default class BXO {
|
|
|
22
22
|
private serverHostname?: string;
|
|
23
23
|
private enableValidation: boolean = true;
|
|
24
24
|
private requestHandler: RequestHandler;
|
|
25
|
+
private serveOptions?: Partial<Bun.ServeFunctionOptions<any, any>>;
|
|
25
26
|
|
|
26
27
|
constructor(options?: BXOOptions) {
|
|
27
28
|
this.enableValidation = options?.enableValidation ?? true;
|
|
29
|
+
this.serveOptions = options?.serve;
|
|
28
30
|
this.requestHandler = new RequestHandler(
|
|
29
31
|
this._routes,
|
|
30
32
|
this._wsRoutes,
|
|
@@ -228,11 +230,27 @@ export default class BXO {
|
|
|
228
230
|
await this.hooks.onBeforeStart(this);
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
|
|
233
|
+
// Merge user's serve options with framework defaults
|
|
234
|
+
const defaultServeOptions = {
|
|
232
235
|
port,
|
|
233
236
|
hostname,
|
|
234
|
-
fetch: (request, server) =>
|
|
235
|
-
|
|
237
|
+
fetch: async (request: Request, server: any): Promise<Response> => {
|
|
238
|
+
const result = await this.requestHandler.handleRequest(request, server);
|
|
239
|
+
return result || new Response('Not Found', { status: 404 });
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Merge user options with defaults, allowing user options to override
|
|
244
|
+
const finalServeOptions = {
|
|
245
|
+
...defaultServeOptions,
|
|
246
|
+
...this.serveOptions,
|
|
247
|
+
// Ensure our fetch handler is always used unless user explicitly overrides
|
|
248
|
+
fetch: this.serveOptions?.fetch || defaultServeOptions.fetch,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Add websocket support if there are WebSocket routes
|
|
252
|
+
if (this.getAllWSRoutes().length > 0) {
|
|
253
|
+
(finalServeOptions as any).websocket = {
|
|
236
254
|
message: (ws: any, message: any) => {
|
|
237
255
|
const handler = ws.data?.handler;
|
|
238
256
|
if (handler?.onMessage) {
|
|
@@ -251,8 +269,10 @@ export default class BXO {
|
|
|
251
269
|
handler.onClose(ws, code, reason);
|
|
252
270
|
}
|
|
253
271
|
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.server = Bun.serve(finalServeOptions as any);
|
|
256
276
|
|
|
257
277
|
// Verify server was created successfully
|
|
258
278
|
if (!this.server) {
|
|
@@ -76,7 +76,8 @@ export class RequestHandler {
|
|
|
76
76
|
|
|
77
77
|
// Process and return response
|
|
78
78
|
const internalCookies = getInternalCookies(ctx);
|
|
79
|
-
|
|
79
|
+
const processedResponse = processResponse(response, ctx, internalCookies, this.enableValidation, route.config);
|
|
80
|
+
return processedResponse;
|
|
80
81
|
|
|
81
82
|
} catch (error) {
|
|
82
83
|
// Run error hooks
|
package/src/types/index.ts
CHANGED
package/src/utils/index.ts
CHANGED
|
@@ -271,6 +271,47 @@ export function cookiesToHeaders(cookies: InternalCookie[]): string[] {
|
|
|
271
271
|
});
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// Special cases for HTTP headers that need specific casing
|
|
275
|
+
const HEADER_CASING_SPECIAL_CASES: Record<string, string> = {
|
|
276
|
+
'www-authenticate': 'WWW-Authenticate',
|
|
277
|
+
'content-md5': 'Content-MD5',
|
|
278
|
+
'dnt': 'DNT',
|
|
279
|
+
'etag': 'ETag',
|
|
280
|
+
'te': 'TE',
|
|
281
|
+
'trailer': 'Trailer',
|
|
282
|
+
'transfer-encoding': 'Transfer-Encoding',
|
|
283
|
+
'upgrade': 'Upgrade',
|
|
284
|
+
'x-forwarded-for': 'X-Forwarded-For',
|
|
285
|
+
'x-forwarded-proto': 'X-Forwarded-Proto',
|
|
286
|
+
'x-forwarded-host': 'X-Forwarded-Host',
|
|
287
|
+
'x-real-ip': 'X-Real-IP',
|
|
288
|
+
'x-requested-with': 'X-Requested-With',
|
|
289
|
+
'x-csrf-token': 'X-CSRF-Token',
|
|
290
|
+
'x-frame-options': 'X-Frame-Options',
|
|
291
|
+
'x-content-type-options': 'X-Content-Type-Options',
|
|
292
|
+
'x-xss-protection': 'X-XSS-Protection',
|
|
293
|
+
'strict-transport-security': 'Strict-Transport-Security',
|
|
294
|
+
'content-security-policy': 'Content-Security-Policy',
|
|
295
|
+
'referrer-policy': 'Referrer-Policy',
|
|
296
|
+
'permissions-policy': 'Permissions-Policy'
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Helper function to normalize header casing while preserving special cases
|
|
300
|
+
function normalizeHeaderCase(headerKey: string): string {
|
|
301
|
+
const lowerKey = headerKey.toLowerCase();
|
|
302
|
+
return HEADER_CASING_SPECIAL_CASES[lowerKey] || headerKey;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Helper function to convert Headers object back to plain object while preserving casing
|
|
306
|
+
export function headersToPlainObject(headers: Headers): Record<string, string> {
|
|
307
|
+
const result: Record<string, string> = {};
|
|
308
|
+
headers.forEach((value, key) => {
|
|
309
|
+
// Preserve the original casing from the Headers object
|
|
310
|
+
result[key] = value;
|
|
311
|
+
});
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
274
315
|
// Merge headers with cookies
|
|
275
316
|
export function mergeHeadersWithCookies(
|
|
276
317
|
headers: Record<string, string>,
|
|
@@ -278,9 +319,10 @@ export function mergeHeadersWithCookies(
|
|
|
278
319
|
): Headers {
|
|
279
320
|
const newHeaders = new Headers();
|
|
280
321
|
|
|
281
|
-
// Add regular headers
|
|
322
|
+
// Add regular headers with proper casing
|
|
282
323
|
Object.entries(headers).forEach(([key, value]) => {
|
|
283
|
-
|
|
324
|
+
const normalizedKey = normalizeHeaderCase(key);
|
|
325
|
+
newHeaders.set(normalizedKey, value);
|
|
284
326
|
});
|
|
285
327
|
|
|
286
328
|
// Add Set-Cookie headers
|
|
@@ -292,6 +334,35 @@ export function mergeHeadersWithCookies(
|
|
|
292
334
|
return newHeaders;
|
|
293
335
|
}
|
|
294
336
|
|
|
337
|
+
// Alternative function that returns headers with preserved casing
|
|
338
|
+
export function mergeHeadersWithCookiesPreserveCasing(
|
|
339
|
+
headers: Record<string, string>,
|
|
340
|
+
cookies: InternalCookie[]
|
|
341
|
+
): Record<string, string> {
|
|
342
|
+
const result: Record<string, string> = { ...headers };
|
|
343
|
+
|
|
344
|
+
// Apply special casing rules
|
|
345
|
+
Object.keys(result).forEach(key => {
|
|
346
|
+
const normalizedKey = normalizeHeaderCase(key);
|
|
347
|
+
if (normalizedKey !== key) {
|
|
348
|
+
result[normalizedKey] = result[key] || '';
|
|
349
|
+
delete result[key];
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Add Set-Cookie headers
|
|
354
|
+
if (cookies.length > 0) {
|
|
355
|
+
const cookieHeaders = cookiesToHeaders(cookies);
|
|
356
|
+
// Set-Cookie headers should be separate entries, not joined
|
|
357
|
+
cookieHeaders.forEach((cookieHeader, index) => {
|
|
358
|
+
const key = index === 0 ? 'Set-Cookie' : `Set-Cookie-${index + 1}`;
|
|
359
|
+
result[key] = cookieHeader;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
|
|
295
366
|
// Create a redirect response
|
|
296
367
|
export function createRedirectResponse(
|
|
297
368
|
location: string,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, InternalCookie } from '../types';
|
|
2
|
-
import { validateResponse, mergeHeadersWithCookies } from './index';
|
|
2
|
+
import { validateResponse, mergeHeadersWithCookies, mergeHeadersWithCookiesPreserveCasing } from './index';
|
|
3
3
|
|
|
4
4
|
// Process and format the response from a route handler
|
|
5
5
|
export function processResponse(
|
|
@@ -13,7 +13,7 @@ export function processResponse(
|
|
|
13
13
|
// automatically create a redirect Response so users can call ctx.redirect(...) or set headers without returning.
|
|
14
14
|
const hasImplicitRedirectIntent = !!ctx.set.redirect
|
|
15
15
|
|| (typeof ctx.set.status === 'number' && ctx.set.status >= 300 && ctx.set.status < 400);
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
if ((response === undefined || response === null) && hasImplicitRedirectIntent) {
|
|
18
18
|
const locationFromHeaders = ctx.set.headers && Object.entries(ctx.set.headers).find(([k]) => k.toLowerCase() === 'location')?.[1];
|
|
19
19
|
const location = ctx.set.redirect?.location || locationFromHeaders;
|
|
@@ -28,12 +28,7 @@ export function processResponse(
|
|
|
28
28
|
|
|
29
29
|
// Handle cookies if any are set
|
|
30
30
|
if (internalCookies.length > 0) {
|
|
31
|
-
|
|
32
|
-
const finalHeaders: Record<string, string> = {};
|
|
33
|
-
headers.forEach((value, key) => {
|
|
34
|
-
finalHeaders[key] = value;
|
|
35
|
-
});
|
|
36
|
-
responseHeaders = finalHeaders;
|
|
31
|
+
responseHeaders = mergeHeadersWithCookiesPreserveCasing(responseHeaders, internalCookies);
|
|
37
32
|
}
|
|
38
33
|
|
|
39
34
|
return new Response(null, {
|
|
@@ -77,7 +72,7 @@ export function processResponse(
|
|
|
77
72
|
if (response instanceof Response) {
|
|
78
73
|
// If there are headers set via ctx.set.headers, merge them with the Response headers
|
|
79
74
|
if (ctx.set.headers && Object.keys(ctx.set.headers).length > 0) {
|
|
80
|
-
const newHeaders =
|
|
75
|
+
const newHeaders = mergeHeadersWithCookiesPreserveCasing(ctx.set.headers, internalCookies);
|
|
81
76
|
|
|
82
77
|
// Create new Response with merged headers
|
|
83
78
|
return new Response(response.body, {
|
|
@@ -124,12 +119,12 @@ export function processResponse(
|
|
|
124
119
|
|
|
125
120
|
// Handle cookies if any are set
|
|
126
121
|
if (internalCookies.length > 0) {
|
|
127
|
-
const headers =
|
|
122
|
+
const headers = mergeHeadersWithCookiesPreserveCasing(responseHeaders, internalCookies);
|
|
128
123
|
|
|
129
124
|
if (typeof response === 'string') {
|
|
130
125
|
// Only set Content-Type to text/plain if not already set
|
|
131
|
-
if (!headers
|
|
132
|
-
headers
|
|
126
|
+
if (!headers['Content-Type']) {
|
|
127
|
+
headers['Content-Type'] = 'text/plain';
|
|
133
128
|
}
|
|
134
129
|
return new Response(response, {
|
|
135
130
|
status: ctx.set.status || 200,
|
|
@@ -163,15 +158,15 @@ export function processResponse(
|
|
|
163
158
|
}
|
|
164
159
|
return value;
|
|
165
160
|
}));
|
|
166
|
-
|
|
167
|
-
headers
|
|
161
|
+
|
|
162
|
+
headers['Content-Type'] = 'application/json';
|
|
168
163
|
return new Response(JSON.stringify(serializableResponse), {
|
|
169
164
|
status: ctx.set.status || 200,
|
|
170
165
|
headers: headers
|
|
171
166
|
});
|
|
172
167
|
}
|
|
173
168
|
|
|
174
|
-
headers
|
|
169
|
+
headers['Content-Type'] = 'application/json';
|
|
175
170
|
return new Response(JSON.stringify(response), {
|
|
176
171
|
status: ctx.set.status || 200,
|
|
177
172
|
headers: headers
|
|
@@ -228,7 +223,7 @@ export function processResponse(
|
|
|
228
223
|
}
|
|
229
224
|
return value;
|
|
230
225
|
}));
|
|
231
|
-
|
|
226
|
+
|
|
232
227
|
return new Response(JSON.stringify(serializableResponse), {
|
|
233
228
|
...responseInit,
|
|
234
229
|
headers: {
|
|
@@ -258,7 +253,7 @@ export function createErrorResponse(
|
|
|
258
253
|
error: errorMessage,
|
|
259
254
|
...(details && { details })
|
|
260
255
|
};
|
|
261
|
-
|
|
256
|
+
|
|
262
257
|
return new Response(JSON.stringify(response), {
|
|
263
258
|
status,
|
|
264
259
|
headers: { 'Content-Type': 'application/json' }
|
|
@@ -271,7 +266,7 @@ export function createValidationErrorResponse(
|
|
|
271
266
|
status: number = 400
|
|
272
267
|
): Response {
|
|
273
268
|
let validationDetails = undefined;
|
|
274
|
-
|
|
269
|
+
|
|
275
270
|
// Handle Error objects
|
|
276
271
|
if (validationError instanceof Error) {
|
|
277
272
|
if ('errors' in validationError && Array.isArray(validationError.errors)) {
|
|
@@ -295,3 +290,4 @@ export function createValidationErrorResponse(
|
|
|
295
290
|
|
|
296
291
|
return createErrorResponse(errorMessage, status, validationDetails);
|
|
297
292
|
}
|
|
293
|
+
|
|
@@ -426,6 +426,24 @@ describe('BXO Framework Integration', () => {
|
|
|
426
426
|
expect(text).toBe('Custom response');
|
|
427
427
|
expect(response.headers.get('x-custom')).toBe('value');
|
|
428
428
|
});
|
|
429
|
+
|
|
430
|
+
it('should handle HTML responses with custom Content-Type', async () => {
|
|
431
|
+
app.get('/html', (ctx) => {
|
|
432
|
+
ctx.set.headers['Content-Type'] = 'text/html';
|
|
433
|
+
return `
|
|
434
|
+
<script src="/dist/injected/frontend.js"></script>
|
|
435
|
+
<html><body>Hello World</body></html>
|
|
436
|
+
`;
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const response = await fetch(`${baseUrl}/html`);
|
|
440
|
+
const text = await response.text();
|
|
441
|
+
|
|
442
|
+
expect(response.status).toBe(200);
|
|
443
|
+
expect(response.headers.get('content-type')).toBe('text/html');
|
|
444
|
+
expect(text).toContain('<script src="/dist/injected/frontend.js"></script>');
|
|
445
|
+
expect(text).toContain('<html><body>Hello World</body></html>');
|
|
446
|
+
});
|
|
429
447
|
});
|
|
430
448
|
|
|
431
449
|
describe('Status and Headers', () => {
|
|
@@ -129,6 +129,32 @@ describe('Response Handler', () => {
|
|
|
129
129
|
expect(await result.text()).toBe('Hello World');
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
it('should preserve custom Content-Type for string responses', async () => {
|
|
133
|
+
mockContext.set.headers = { 'Content-Type': 'text/html' };
|
|
134
|
+
const result = processResponse('<html><body>Hello World</body></html>', mockContext, mockInternalCookies, true);
|
|
135
|
+
|
|
136
|
+
expect(result.status).toBe(200);
|
|
137
|
+
expect(result.headers.get('content-type')).toBe('text/html');
|
|
138
|
+
expect(await result.text()).toBe('<html><body>Hello World</body></html>');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should preserve custom Content-Type for string responses without cookies', async () => {
|
|
142
|
+
mockContext.set.headers = { 'Content-Type': 'text/html' };
|
|
143
|
+
const result = processResponse('<html><body>Hello World</body></html>', mockContext, [], true);
|
|
144
|
+
|
|
145
|
+
expect(result.status).toBe(200);
|
|
146
|
+
expect(result.headers.get('content-type')).toBe('text/html');
|
|
147
|
+
expect(await result.text()).toBe('<html><body>Hello World</body></html>');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should default to text/plain when no Content-Type is set for string responses', async () => {
|
|
151
|
+
const result = processResponse('Hello World', mockContext, [], true);
|
|
152
|
+
|
|
153
|
+
expect(result.status).toBe(200);
|
|
154
|
+
expect(result.headers.get('content-type')).toBe('text/plain');
|
|
155
|
+
expect(await result.text()).toBe('Hello World');
|
|
156
|
+
});
|
|
157
|
+
|
|
132
158
|
it('should handle object responses', async () => {
|
|
133
159
|
const result = processResponse({ message: 'success' }, mockContext, mockInternalCookies, true);
|
|
134
160
|
|
package/tests/unit/utils.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
parseRequestBody,
|
|
9
9
|
cookiesToHeaders,
|
|
10
10
|
mergeHeadersWithCookies,
|
|
11
|
+
headersToPlainObject,
|
|
11
12
|
createRedirectResponse,
|
|
12
13
|
isFileUpload,
|
|
13
14
|
getFileFromUpload,
|
|
@@ -178,6 +179,47 @@ describe('Utility Functions', () => {
|
|
|
178
179
|
expect(result.get('content-type')).toBe('application/json');
|
|
179
180
|
expect(result.get('set-cookie')).toBe('session=abc123');
|
|
180
181
|
});
|
|
182
|
+
|
|
183
|
+
it('should preserve special header casing', () => {
|
|
184
|
+
const headers = {
|
|
185
|
+
'WWW-Authenticate': 'Basic realm="example"',
|
|
186
|
+
'x-frame-options': 'DENY',
|
|
187
|
+
'content-type': 'application/json'
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = mergeHeadersWithCookies(headers, []);
|
|
191
|
+
|
|
192
|
+
// Check that special headers maintain their casing
|
|
193
|
+
expect(result.get('WWW-Authenticate')).toBe('Basic realm="example"');
|
|
194
|
+
expect(result.get('X-Frame-Options')).toBe('DENY');
|
|
195
|
+
// Regular headers should keep their original casing
|
|
196
|
+
expect(result.get('content-type')).toBe('application/json');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('headersToPlainObject', () => {
|
|
201
|
+
it('should convert Headers object to plain object', () => {
|
|
202
|
+
const headers = new Headers();
|
|
203
|
+
headers.set('www-authenticate', 'Basic realm="example"');
|
|
204
|
+
headers.set('x-frame-options', 'DENY');
|
|
205
|
+
headers.set('content-type', 'application/json');
|
|
206
|
+
|
|
207
|
+
const result = headersToPlainObject(headers);
|
|
208
|
+
|
|
209
|
+
// Note: Headers object normalizes keys to lowercase
|
|
210
|
+
expect(result).toEqual({
|
|
211
|
+
'www-authenticate': 'Basic realm="example"',
|
|
212
|
+
'x-frame-options': 'DENY',
|
|
213
|
+
'content-type': 'application/json'
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should handle empty Headers object', () => {
|
|
218
|
+
const headers = new Headers();
|
|
219
|
+
const result = headersToPlainObject(headers);
|
|
220
|
+
|
|
221
|
+
expect(result).toEqual({});
|
|
222
|
+
});
|
|
181
223
|
});
|
|
182
224
|
|
|
183
225
|
describe('createRedirectResponse', () => {
|