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
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(
|
|
@@ -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,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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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', () => {
|