@hyperspan/framework 1.0.0-alpha.8 → 1.0.0

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/src/client/js.ts CHANGED
@@ -1,61 +1,94 @@
1
1
  import { html } from '@hyperspan/html';
2
+ import { assetHash as assetHashFn } from '../utils';
3
+ import { join } from 'node:path';
4
+ import type { Hyperspan as HS } from '../types';
5
+
6
+ const CWD = process.cwd();
7
+ const IS_PROD = process.env.NODE_ENV === 'production';
2
8
 
3
9
  export const JS_PUBLIC_PATH = '/_hs/js';
4
10
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
5
11
  export const JS_IMPORT_MAP = new Map<string, string>();
12
+ const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
13
+ const EXPORT_REGEX = /export\{(.*)\}/g;
6
14
 
7
15
  /**
8
- * Render a client JS module as a script tag
16
+ * Build a client JS module and return a Hyperspan.ClientJSBuildResult object
9
17
  */
10
- export function renderClientJS<T>(module: T, loadScript?: ((module: T) => void) | string) {
11
- // @ts-ignore
12
- if (!module.__CLIENT_JS) {
13
- throw new Error(
14
- `[Hyperspan] Client JS was not loaded by Hyperspan! Ensure the filename ends with .client.ts to use this render method.`
15
- );
16
- }
18
+ export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
19
+ const modulePath = modulePathResolved.replace('file://', '');
20
+ const assetHash = assetHashFn(modulePath);
17
21
 
18
- return html.raw(
19
- // @ts-ignore
20
- module.__CLIENT_JS.renderScriptTag({
21
- loadScript: loadScript
22
- ? typeof loadScript === 'string'
23
- ? loadScript
24
- : functionToString(loadScript)
25
- : undefined,
26
- })
27
- );
28
- }
22
+ // Cache: Avoid re-processing the same file
23
+ if (!CLIENT_JS_CACHE.has(assetHash)) {
24
+ // Build the client JS module
25
+ const result = await Bun.build({
26
+ entrypoints: [modulePath],
27
+ outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
28
+ naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
29
+ external: Array.from(JS_IMPORT_MAP.keys()),
30
+ minify: true,
31
+ format: 'esm',
32
+ target: 'browser',
33
+ env: 'APP_PUBLIC_*',
34
+ });
29
35
 
30
- /**
31
- * Convert a function to a string (results in loss of context!)
32
- * Handles named, async, and arrow functions
33
- */
34
- export function functionToString(fn: any) {
35
- let str = fn.toString().trim();
36
-
37
- // Ensure consistent output & handle async
38
- if (!str.includes('function ')) {
39
- if (str.includes('async ')) {
40
- str = 'async function ' + str.replace('async ', '');
41
- } else {
42
- str = 'function ' + str;
36
+ // Add output file to import map
37
+ const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
38
+ const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
39
+ JS_IMPORT_MAP.set(esmName, publicPath);
40
+
41
+ // Get the contents of the file to extract the exports
42
+ const contents = await result.outputs[0].text();
43
+ const exportLine = EXPORT_REGEX.exec(contents);
44
+
45
+ let exports = '{}';
46
+ if (exportLine) {
47
+ const exportName = exportLine[1];
48
+ exports =
49
+ '{' +
50
+ exportName
51
+ .split(',')
52
+ .map((name) => name.trim().split(' as '))
53
+ .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
54
+ .join(', ') +
55
+ '}';
43
56
  }
57
+ const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
58
+ CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
44
59
  }
45
60
 
46
- const lines = str.split('\n');
47
- const firstLine = lines[0];
48
- const lastLine = lines[lines.length - 1];
61
+ const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
49
62
 
50
- // Arrow function conversion
51
- if (!lastLine?.includes('}')) {
52
- return str.replace('=> ', '{ return ') + '; }';
53
- }
63
+ return {
64
+ assetHash,
65
+ esmName,
66
+ publicPath,
67
+ renderScriptTag: (loadScript) => {
68
+ const t = typeof loadScript;
69
+
70
+ if (t === 'string') {
71
+ return html`
72
+ <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
73
+ `;
74
+ }
75
+ if (t === 'function') {
76
+ return html`
77
+ <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
78
+ `;
79
+ }
54
80
 
55
- // Cleanup arrow function
56
- if (firstLine.includes('=>')) {
57
- return str.replace('=> ', '');
81
+ return html`
82
+ <script type="module" data-source-id="${assetHash}">import "${esmName}";</script>
83
+ `;
84
+ }
58
85
  }
86
+ }
59
87
 
60
- return str;
88
+ /**
89
+ * Convert a function to a string (results in loss of context!)
90
+ * Handles named, async, and arrow functions
91
+ */
92
+ export function functionToString(fn: any) {
93
+ return fn.toString().trim();
61
94
  }
package/src/cookies.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type { Hyperspan as HS } from './types';
2
+
3
+ const REGEXP_PAIR_SPLIT = /; */;
4
+
5
+ /**
6
+ * RegExp to match field-content in RFC 7230 sec 3.2
7
+ *
8
+ * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
9
+ * field-vchar = VCHAR / obs-text
10
+ * obs-text = %x80-FF
11
+ */
12
+
13
+ const REGEXP_FIELD_CONTENT = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;
14
+
15
+ /**
16
+ * Cookie parsing and serialization
17
+ */
18
+ export class Cookies implements HS.Cookies {
19
+ _req: Request;
20
+ _responseHeaders: HS.Cookies['_responseHeaders'];
21
+ _parsedCookies: HS.Cookies['_parsedCookies'] = {};
22
+ _encrypt: HS.Cookies['_encrypt'];
23
+ _decrypt: HS.Cookies['_decrypt'];
24
+ constructor(req: Request, responseHeaders: HS.Cookies['_responseHeaders'] = undefined) {
25
+ this._req = req;
26
+ this._responseHeaders = responseHeaders;
27
+ this._parsedCookies = parse(req.headers.get('Cookie') || '');
28
+ }
29
+
30
+ get(name: string): string | undefined {
31
+ const value = this._parsedCookies[name];
32
+ if (value && this._decrypt) {
33
+ return this._decrypt(value);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ set(name: string, value: string, options?: HS.CookieOptions) {
39
+ if (!this._responseHeaders) {
40
+ throw new Error('Set cookies in the response object. Cookies can only be read from the request object.');
41
+ }
42
+ if (this._encrypt) {
43
+ value = this._encrypt(value);
44
+ }
45
+ this._responseHeaders.append('Set-Cookie', serialize(name, value, options));
46
+ }
47
+
48
+ delete(name: string) {
49
+ this.set(name, '', { expires: new Date(0) });
50
+ }
51
+
52
+ /**
53
+ * Set the encoder and decoder functions for the cookies and re-parse the cookie header
54
+ */
55
+ setEncryption(encrypt: HS.Cookies['_encrypt'], decrypt: HS.Cookies['_decrypt']) {
56
+ this._encrypt = encrypt;
57
+ this._decrypt = decrypt;
58
+ this._parsedCookies = parse(this._req.headers.get('Cookie') || '');
59
+ }
60
+ }
61
+
62
+ /*!
63
+ * cookie
64
+ * @source https://github.com/jkohrman/cookie-parse/blob/master/index.js
65
+ * Copyright(c) 2012-2014 Roman Shtylman
66
+ * Copyright(c) 2015 Douglas Christopher Wilson
67
+ * Copyright(c) 2016 Jeff Kohrman
68
+ * MIT Licensed
69
+ */
70
+
71
+ /**
72
+ * Parse a cookie header.
73
+ *
74
+ * Parse the given cookie header string into an object
75
+ * The object has the various cookies as keys(names) => values
76
+ *
77
+ * @param {string} str
78
+ * @param {object} [options]
79
+ * @return {object}
80
+ * @public
81
+ */
82
+
83
+ function parse(str: string): Record<string, string | any | undefined> {
84
+ if (typeof str !== 'string') {
85
+ throw new TypeError('argument str must be a string');
86
+ }
87
+
88
+ const obj = {}
89
+ const pairs = str.split(REGEXP_PAIR_SPLIT);
90
+
91
+ for (let i = 0; i < pairs.length; i++) {
92
+ const pair = pairs[i];
93
+ let eq_idx = pair.indexOf('=');
94
+
95
+ // set true for things that don't look like key=value
96
+ let key;
97
+ let val;
98
+ if (eq_idx < 0) {
99
+ key = pair.trim();
100
+ val = 'true';
101
+ } else {
102
+ key = pair.substring(0, eq_idx).trim()
103
+ val = pair.substring(eq_idx + 1, eq_idx + 1 + pair.length).trim();
104
+ };
105
+
106
+ // quoted values
107
+ if ('"' == val[0]) {
108
+ val = val.slice(1, -1);
109
+ }
110
+
111
+ // only assign once
112
+ // @ts-ignore
113
+ if (undefined == obj[key]) {
114
+ // @ts-ignore
115
+ obj[key] = tryDecode(val, decodeURIComponent);
116
+ }
117
+ }
118
+
119
+ return obj;
120
+ }
121
+
122
+ /**
123
+ * Serialize data into a cookie header.
124
+ *
125
+ * Serialize the a name value pair into a cookie string suitable for
126
+ * http headers. An optional options object specified cookie parameters.
127
+ *
128
+ * serialize('foo', 'bar', { httpOnly: true })
129
+ * => "foo=bar; httpOnly"
130
+ *
131
+ * @param {string} name
132
+ * @param {string} val
133
+ * @param {object} [options]
134
+ * @return {string}
135
+ * @public
136
+ */
137
+ type SerializeOptions = {
138
+ encrypt?: (str: string) => string;
139
+ maxAge?: number;
140
+ domain?: string;
141
+ path?: string;
142
+ expires?: Date;
143
+ httpOnly?: boolean;
144
+ secure?: boolean;
145
+ sameSite?: 'lax' | 'strict' | true;
146
+ };
147
+ function serialize(name: string, val: string, options: SerializeOptions = {}) {
148
+ const opt = options || {};
149
+
150
+ if (!REGEXP_FIELD_CONTENT.test(name)) {
151
+ throw new TypeError('argument name is invalid');
152
+ }
153
+
154
+ let value = encodeURIComponent(val);
155
+
156
+ if (value && !REGEXP_FIELD_CONTENT.test(value)) {
157
+ throw new TypeError('argument val is invalid');
158
+ }
159
+
160
+ let str = name + '=' + value;
161
+
162
+ if (null != opt.maxAge) {
163
+ const maxAge = opt.maxAge - 0;
164
+ if (isNaN(maxAge)) throw new Error('maxAge should be a Number');
165
+ str += '; Max-Age=' + Math.floor(maxAge);
166
+ }
167
+
168
+ if (opt.domain) {
169
+ if (!REGEXP_FIELD_CONTENT.test(opt.domain)) {
170
+ throw new TypeError('option domain is invalid');
171
+ }
172
+
173
+ str += '; Domain=' + opt.domain;
174
+ }
175
+
176
+ if (opt.path) {
177
+ if (!REGEXP_FIELD_CONTENT.test(opt.path)) {
178
+ throw new TypeError('option path is invalid');
179
+ }
180
+
181
+ str += '; Path=' + opt.path;
182
+ }
183
+
184
+ if (opt.expires) {
185
+ if (typeof opt.expires.toUTCString !== 'function') {
186
+ throw new TypeError('option expires must be a Date object');
187
+ }
188
+
189
+ str += '; Expires=' + opt.expires.toUTCString();
190
+ }
191
+
192
+ if (opt.httpOnly) {
193
+ str += '; HttpOnly';
194
+ }
195
+
196
+ if (opt.secure) {
197
+ str += '; Secure';
198
+ }
199
+
200
+ if (opt.sameSite) {
201
+ const sameSite = typeof opt.sameSite === 'string'
202
+ ? opt.sameSite.toLowerCase() : opt.sameSite;
203
+
204
+ switch (sameSite) {
205
+ case true:
206
+ case 'strict':
207
+ str += '; SameSite=Strict';
208
+ break;
209
+ case 'lax':
210
+ str += '; SameSite=Lax';
211
+ break;
212
+ default:
213
+ throw new TypeError('option sameSite is invalid');
214
+ }
215
+ }
216
+
217
+ return str;
218
+ }
219
+
220
+ /**
221
+ * Try decoding a string using a decoding function.
222
+ *
223
+ * @param {string} str
224
+ * @param {function} decode
225
+ * @private
226
+ */
227
+
228
+ function tryDecode(str: string, decode: (str: string) => string) {
229
+ try {
230
+ return decode(str);
231
+ } catch (e) {
232
+ return str;
233
+ }
234
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPException } from './server';
1
+ export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPResponseException } from './server';
2
2
  export type { Hyperspan } from './types';
package/src/layout.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { html } from '@hyperspan/html';
2
- import { JS_IMPORT_MAP } from './client/js';
2
+ import { JS_IMPORT_MAP, buildClientJS } from './client/js';
3
3
  import { CSS_PUBLIC_PATH, CSS_ROUTE_MAP } from './client/css';
4
4
  import type { Hyperspan as HS } from './types';
5
5
 
6
+ const clientStreamingJS = await buildClientJS(import.meta.resolve('./client/_hs/hyperspan-streaming.client'));
7
+
6
8
  /**
7
9
  * Output the importmap for the client so we can use ESModules on the client to load JS files on demand
8
10
  */
@@ -11,6 +13,27 @@ export function hyperspanScriptTags() {
11
13
  <script type="importmap">
12
14
  {"imports": ${Object.fromEntries(JS_IMPORT_MAP)}}
13
15
  </script>
16
+ <script id="hyperspan-streaming-script">
17
+ // [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
18
+ window._hsc = window._hsc || [];
19
+ var hscc = function(e) {
20
+ if (window._hscc !== undefined) {
21
+ window._hscc(e);
22
+ }
23
+ };
24
+ window._hsc.push = function(e) {
25
+ Array.prototype.push.call(window._hsc, e);
26
+ if (window._hsc.length === 1) {
27
+ const script = document.createElement('script');
28
+ script.src = "${clientStreamingJS.publicPath}";
29
+ document.body.appendChild(script);
30
+ script.onload = function() {
31
+ hscc(e);
32
+ };
33
+ }
34
+ hscc(e);
35
+ };
36
+ </script>
14
37
  `;
15
38
  }
16
39
 
package/src/middleware.ts CHANGED
@@ -1,4 +1,91 @@
1
+ import { formDataToJSON } from './utils';
2
+ import { z, flattenError } from 'zod/v4';
3
+
4
+ import type { ZodAny, ZodObject, ZodError } from 'zod/v4';
1
5
  import type { Hyperspan as HS } from './types';
6
+ import { HTTPResponseException } from './server';
7
+
8
+ export type TValidationType = 'json' | 'form' | 'urlencoded';
9
+
10
+ /**
11
+ * Infer the validation type from the request Content-Type header
12
+ */
13
+ function inferValidationType(headers: Headers): TValidationType {
14
+ const contentType = headers.get('content-type')?.toLowerCase() || '';
15
+
16
+ if (contentType.includes('application/json')) {
17
+ return 'json';
18
+ } else if (contentType.includes('multipart/form-data')) {
19
+ return 'form';
20
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
21
+ return 'urlencoded';
22
+ }
23
+
24
+ // Default to json if content-type is not recognized
25
+ return 'json';
26
+ }
27
+
28
+ export class ZodValidationError extends Error {
29
+ constructor(flattened: ReturnType<typeof flattenError>) {
30
+ super('Input validation error(s)');
31
+ this.name = 'ZodValidationError';
32
+
33
+ // Copy all properties from flattened error
34
+ Object.assign(this, flattened);
35
+ }
36
+ }
37
+
38
+ export function validateQuery(schema: ZodObject | ZodAny): HS.MiddlewareFunction {
39
+ return async (context: HS.Context, next: HS.NextFunction) => {
40
+ const query = formDataToJSON(context.req.query);
41
+ const validated = schema.safeParse(query);
42
+
43
+ if (!validated.success) {
44
+ const err = formatZodError(validated.error);
45
+ return context.res.error(err, { status: 400 });
46
+ }
47
+
48
+ // Store the validated query in the context variables
49
+ context.vars.query = validated.data as z.infer<typeof schema>;
50
+
51
+ return next();
52
+ }
53
+ }
54
+
55
+ export function validateBody(schema: ZodObject | ZodAny, type?: TValidationType): HS.MiddlewareFunction {
56
+ return async (context: HS.Context, next: HS.NextFunction) => {
57
+ // Infer type from Content-Type header if not provided
58
+ const validationType = type || inferValidationType(context.req.headers);
59
+
60
+ let body: unknown = {};
61
+ if (validationType === 'json') {
62
+ body = await context.req.raw.json();
63
+ } else if (validationType === 'form') {
64
+ const formData = await context.req.formData();
65
+ body = formDataToJSON(formData as FormData);
66
+ } else if (validationType === 'urlencoded') {
67
+ const urlencoded = await context.req.urlencoded();
68
+ body = formDataToJSON(urlencoded);
69
+ }
70
+ const validated = schema.safeParse(body);
71
+
72
+ if (!validated.success) {
73
+ const err = formatZodError(validated.error);
74
+ throw new HTTPResponseException(err, { status: 400 });
75
+ //return context.res.error(err, { status: 400 });
76
+ }
77
+
78
+ // Store the validated body in the context variables
79
+ context.vars.body = validated.data as z.infer<typeof schema>;
80
+
81
+ return next();
82
+ }
83
+ }
84
+
85
+ export function formatZodError(error: ZodError): ZodValidationError {
86
+ const zodError = flattenError(error);
87
+ return new ZodValidationError(zodError);
88
+ }
2
89
 
3
90
  /**
4
91
  * Type guard to check if a handler is a middleware function
@@ -55,4 +142,3 @@ export async function executeMiddleware(
55
142
  // Start execution from the first handler
56
143
  return await createNext(0)();
57
144
  }
58
-
@@ -1,5 +1,5 @@
1
1
  import { test, expect } from 'bun:test';
2
- import { createRoute, createServer } from './server';
2
+ import { createRoute, createServer, createContext } from './server';
3
3
  import type { Hyperspan as HS } from './types';
4
4
 
5
5
  test('route fetch() returns a Response', async () => {
@@ -89,3 +89,118 @@ test('returns 405 when route path matches but HTTP method does not', async () =>
89
89
  const text = await response.text();
90
90
  expect(text).toContain('Method not allowed');
91
91
  });
92
+
93
+ test('createContext() can get and set cookies', () => {
94
+ // Create a request with cookies in the Cookie header
95
+ const request = new Request('http://localhost:3000/', {
96
+ headers: {
97
+ 'Cookie': 'sessionId=abc123; theme=dark; userId=42',
98
+ },
99
+ });
100
+
101
+ // Create context from the request
102
+ const context = createContext(request);
103
+
104
+ // Test reading cookies from request
105
+ expect(context.req.cookies.get('sessionId')).toBe('abc123');
106
+ expect(context.req.cookies.get('theme')).toBe('dark');
107
+ expect(context.req.cookies.get('userId')).toBe('42');
108
+ expect(context.req.cookies.get('nonExistent')).toBeUndefined();
109
+
110
+ // Test setting a simple cookie in response
111
+ context.res.cookies.set('newCookie', 'newValue');
112
+ let setCookieHeader = context.res.headers.get('Set-Cookie');
113
+ expect(setCookieHeader).toBeTruthy();
114
+ expect(setCookieHeader).toContain('newCookie=newValue');
115
+
116
+ // Test setting a cookie with options (this should NOT overwrite the previous Set-Cookie header)
117
+ context.res.cookies.set('secureCookie', 'secureValue', {
118
+ httpOnly: true,
119
+ secure: true,
120
+ sameSite: 'strict',
121
+ maxAge: 3600,
122
+ });
123
+
124
+ // Verify Set-Cookie header contains the last cookie set
125
+ setCookieHeader = context.res.headers.get('Set-Cookie');
126
+ expect(setCookieHeader).toBeTruthy();
127
+ expect(setCookieHeader).toContain('secureCookie=secureValue');
128
+ expect(setCookieHeader).toContain('newCookie=newValue');
129
+
130
+ // Test deleting a cookie
131
+ context.res.cookies.delete('sessionId');
132
+ setCookieHeader = context.res.headers.get('Set-Cookie');
133
+ expect(setCookieHeader).toBeTruthy();
134
+ if (setCookieHeader) {
135
+ expect(setCookieHeader).toContain('sessionId=');
136
+ expect(setCookieHeader).toContain('Expires=');
137
+ // Verify it's set to expire in the past (deleted)
138
+ const expiresMatch = setCookieHeader.match(/Expires=([^;]+)/);
139
+ expect(expiresMatch).toBeTruthy();
140
+ if (expiresMatch) {
141
+ const expiresDate = new Date(expiresMatch[1]);
142
+ expect(expiresDate.getTime()).toBeLessThanOrEqual(new Date(0).getTime());
143
+ }
144
+ }
145
+ });
146
+
147
+ test('createContext() merge() function preserves custom headers when using response methods', () => {
148
+ // Create a request
149
+ const request = new Request('http://localhost:3000/');
150
+
151
+ // Create context from the request
152
+ const context = createContext(request);
153
+
154
+ // Set custom headers on the context response
155
+ context.res.headers.set('X-Custom-Header', 'custom-value');
156
+ context.res.headers.set('X-Another-Header', 'another-value');
157
+ context.res.headers.set('Authorization', 'Bearer token123');
158
+
159
+ // Use html() method which should merge headers
160
+ const response = context.res.html('<h1>Test</h1>');
161
+
162
+ // Verify the response has both the custom headers and the Content-Type header
163
+ expect(response.headers.get('X-Custom-Header')).toBe('custom-value');
164
+ expect(response.headers.get('X-Another-Header')).toBe('another-value');
165
+ expect(response.headers.get('Authorization')).toBe('Bearer token123');
166
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
167
+
168
+ // Verify response body is correct
169
+ expect(response.status).toBe(200);
170
+ });
171
+
172
+ test('createContext() merge() function preserves custom headers with json() method', () => {
173
+ const request = new Request('http://localhost:3000/');
174
+ const context = createContext(request);
175
+
176
+ // Set custom headers
177
+ context.res.headers.set('X-API-Version', 'v1');
178
+ context.res.headers.set('X-Request-ID', 'req-123');
179
+
180
+ // Use json() method
181
+ const response = context.res.json({ message: 'Hello' });
182
+
183
+ // Verify headers are merged
184
+ expect(response.headers.get('X-API-Version')).toBe('v1');
185
+ expect(response.headers.get('X-Request-ID')).toBe('req-123');
186
+ expect(response.headers.get('Content-Type')).toBe('application/json');
187
+ });
188
+
189
+ test('createContext() merge() function allows response headers to override context headers', () => {
190
+ const request = new Request('http://localhost:3000/');
191
+ const context = createContext(request);
192
+
193
+ // Set a header on context
194
+ context.res.headers.set('X-Header', 'context-value');
195
+
196
+ // Use html() with options that include the same header (should override)
197
+ const response = context.res.html('<h1>Test</h1>', {
198
+ headers: {
199
+ 'X-Header': 'response-value',
200
+ },
201
+ });
202
+
203
+ // Response header should override context header
204
+ expect(response.headers.get('X-Header')).toBe('response-value');
205
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
206
+ });