bxo 0.0.5-dev.62 → 0.0.5-dev.64

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bxo",
3
3
  "module": "index.ts",
4
- "version": "0.0.5-dev.62",
4
+ "version": "0.0.5-dev.64",
5
5
  "description": "A simple and lightweight web framework for Bun",
6
6
  "type": "module",
7
7
  "exports": {
@@ -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
- newHeaders.set(key, value);
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(
@@ -28,12 +28,7 @@ export function processResponse(
28
28
 
29
29
  // Handle cookies if any are set
30
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;
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 = mergeHeadersWithCookies(ctx.set.headers, internalCookies);
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,10 +119,13 @@ export function processResponse(
124
119
 
125
120
  // Handle cookies if any are set
126
121
  if (internalCookies.length > 0) {
127
- const headers = mergeHeadersWithCookies(responseHeaders, internalCookies);
122
+ const headers = mergeHeadersWithCookiesPreserveCasing(responseHeaders, internalCookies);
128
123
 
129
124
  if (typeof response === 'string') {
130
- headers.set('Content-Type', 'text/plain');
125
+ // Only set Content-Type to text/plain if not already set
126
+ if (!headers['Content-Type']) {
127
+ headers['Content-Type'] = 'text/plain';
128
+ }
131
129
  return new Response(response, {
132
130
  status: ctx.set.status || 200,
133
131
  headers: headers
@@ -161,14 +159,14 @@ export function processResponse(
161
159
  return value;
162
160
  }));
163
161
 
164
- headers.set('Content-Type', 'application/json');
162
+ headers['Content-Type'] = 'application/json';
165
163
  return new Response(JSON.stringify(serializableResponse), {
166
164
  status: ctx.set.status || 200,
167
165
  headers: headers
168
166
  });
169
167
  }
170
168
 
171
- headers.set('Content-Type', 'application/json');
169
+ headers['Content-Type'] = 'application/json';
172
170
  return new Response(JSON.stringify(response), {
173
171
  status: ctx.set.status || 200,
174
172
  headers: headers
@@ -182,12 +180,20 @@ export function processResponse(
182
180
  };
183
181
 
184
182
  if (typeof response === 'string') {
183
+ const finalHeaders: Record<string, string> = {};
184
+ // Copy existing headers if they exist
185
+ if (responseInit.headers) {
186
+ if (typeof responseInit.headers === 'object' && !Array.isArray(responseInit.headers)) {
187
+ Object.assign(finalHeaders, responseInit.headers);
188
+ }
189
+ }
190
+ // Only set Content-Type to text/plain if not already set
191
+ if (!finalHeaders['Content-Type']) {
192
+ finalHeaders['Content-Type'] = 'text/plain';
193
+ }
185
194
  return new Response(response, {
186
195
  ...responseInit,
187
- headers: {
188
- 'Content-Type': 'text/plain',
189
- ...responseInit.headers
190
- }
196
+ headers: finalHeaders
191
197
  });
192
198
  }
193
199
 
@@ -284,3 +290,4 @@ export function createValidationErrorResponse(
284
290
 
285
291
  return createErrorResponse(errorMessage, status, validationDetails);
286
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
 
@@ -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', () => {