@hyperspan/framework 1.0.15 → 1.0.17

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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(bun test:*)"
5
+ ]
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -77,6 +77,27 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
77
77
  submitBtn.setAttribute('disabled', 'disabled');
78
78
  }
79
79
 
80
+ function applyResponseHtml(html: string) {
81
+ const isFullDocument = html.includes('<html');
82
+ if (isFullDocument) {
83
+ html = html.replace(/^[\s\uFEFF]*<!DOCTYPE[^>]*>/i, '');
84
+ }
85
+ const target = isFullDocument ? window.document : hsActionTag || form;
86
+ const options = isFullDocument ? undefined : { morphStyle: 'innerHTML' };
87
+
88
+ Idiomorph.morph(target, html, options);
89
+
90
+ if (!isFullDocument) {
91
+ const outerElement = target.querySelector('hs-action');
92
+ if (outerElement) {
93
+ outerElement.replaceWith(...outerElement.childNodes);
94
+ }
95
+ }
96
+
97
+ opts.afterResponse && opts.afterResponse();
98
+ lazyLoadScripts();
99
+ }
100
+
80
101
  fetch(formUrl, { body: formData, method, headers })
81
102
  .then((res: Response) => {
82
103
  // Look for special header that indicates a redirect.
@@ -84,6 +105,17 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
84
105
  if (res.headers.has('X-Redirect-Location')) {
85
106
  const newUrl = res.headers.get('X-Redirect-Location');
86
107
  if (newUrl) {
108
+ const resolved = new URL(newUrl, window.location.href);
109
+
110
+ // If the new URL is the same as the current URL, we can just fetch the new HTML and apply it
111
+ if (resolved.pathname === window.location.pathname) {
112
+ return fetch(resolved.href, {
113
+ headers: { Accept: 'text/html' },
114
+ })
115
+ .then((r) => r.text());
116
+ }
117
+
118
+ // If the new URL is different, we need to redirect the user to the new URL
87
119
  window.location.assign(newUrl);
88
120
  }
89
121
  return '';
@@ -97,17 +129,9 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
97
129
  return;
98
130
  }
99
131
 
100
- const target = content.includes('<html') ? window.document.body : hsActionTag || form;
101
-
102
- Idiomorph.morph(target, content, { morphStyle: 'innerHTML' });
103
-
104
- // Check for nested hs-action elements and remove them if present
105
- const outerElement = target.querySelector('hs-action');
106
- if (outerElement) {
107
- outerElement.replaceWith(...outerElement.childNodes);
108
- }
109
-
110
- opts.afterResponse && opts.afterResponse();
111
- lazyLoadScripts();
132
+ applyResponseHtml(content);
133
+ })
134
+ .catch((error) => {
135
+ console.error('[Hyperspan] Error submitting form action:', error);
112
136
  });
113
137
  }
@@ -214,6 +214,75 @@ test('createContext() merge() function preserves custom headers with json() meth
214
214
  expect(response.headers.get('Content-Type')).toBe('application/json');
215
215
  });
216
216
 
217
+ test('route returning AsyncGenerator produces a streaming response', async () => {
218
+ async function* streamingHandler() {
219
+ yield '<h1>Hello</h1>';
220
+ yield '<p>World</p>';
221
+ yield '<p>Streaming</p>';
222
+ }
223
+
224
+ const route = createRoute().get(async (_context: HS.Context) => {
225
+ return streamingHandler();
226
+ });
227
+
228
+ const request = new Request('http://localhost:3000/');
229
+ const response = await route.fetch(request);
230
+
231
+ expect(response).toBeInstanceOf(Response);
232
+ expect(response.status).toBe(200);
233
+ expect(response.headers.get('Transfer-Encoding')).toBe('chunked');
234
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
235
+
236
+ const text = await response.text();
237
+ expect(text).toBe('<h1>Hello</h1><p>World</p><p>Streaming</p>');
238
+ });
239
+
240
+ test('route returning a sync Generator produces a streaming response', async () => {
241
+ function* streamingHandler() {
242
+ yield '<h1>Hello</h1>';
243
+ yield '<p>World</p>';
244
+ yield '<p>Streaming</p>';
245
+ }
246
+
247
+ const route = createRoute().get((_context: HS.Context) => {
248
+ return streamingHandler();
249
+ });
250
+
251
+ const request = new Request('http://localhost:3000/');
252
+ const response = await route.fetch(request);
253
+
254
+ expect(response).toBeInstanceOf(Response);
255
+ expect(response.status).toBe(200);
256
+ expect(response.headers.get('Transfer-Encoding')).toBe('chunked');
257
+ expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
258
+
259
+ const text = await response.text();
260
+ expect(text).toBe('<h1>Hello</h1><p>World</p><p>Streaming</p>');
261
+ });
262
+
263
+ test('route returning a Generator respects Content-Type set on context.res.headers', async () => {
264
+ async function* streamingHandler() {
265
+ yield 'Hello';
266
+ yield ' World';
267
+ }
268
+
269
+ const route = createRoute().get((context: HS.Context) => {
270
+ context.res.headers.set('Content-Type', 'text/plain');
271
+ return streamingHandler();
272
+ });
273
+
274
+ const request = new Request('http://localhost:3000/');
275
+ const response = await route.fetch(request);
276
+
277
+ expect(response).toBeInstanceOf(Response);
278
+ expect(response.status).toBe(200);
279
+ expect(response.headers.get('Transfer-Encoding')).toBe('chunked');
280
+ expect(response.headers.get('Content-Type')).toBe('text/plain');
281
+
282
+ const text = await response.text();
283
+ expect(text).toBe('Hello World');
284
+ });
285
+
217
286
  test('createContext() merge() function allows response headers to override context headers', async () => {
218
287
  const request = new Request('http://localhost:3000/');
219
288
  const context = createContext(request);
package/src/server.ts CHANGED
@@ -291,7 +291,7 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
291
291
 
292
292
  const contentType = _typeOf(routeContent);
293
293
  if (contentType === 'generator') {
294
- return new StreamResponse(routeContent as AsyncGenerator);
294
+ return new StreamResponse(routeContent as AsyncGenerator, { headers: Object.fromEntries(context.res.headers.entries()) });
295
295
  }
296
296
 
297
297
  return routeContent;
@@ -545,14 +545,20 @@ export class StreamResponse extends Response {
545
545
  const { status, headers, ...restOptions } = options;
546
546
  const stream = createReadableStreamFromAsyncGenerator(iterator as AsyncGenerator);
547
547
 
548
+ const mergedHeaders = new Headers({
549
+ 'Transfer-Encoding': 'chunked',
550
+ 'Content-Type': 'text/html; charset=UTF-8',
551
+ 'Content-Encoding': 'Identity',
552
+ });
553
+ if (headers) {
554
+ for (const [key, value] of Object.entries(headers)) {
555
+ mergedHeaders.set(key, value);
556
+ }
557
+ }
558
+
548
559
  return new Response(stream, {
549
560
  status: status ?? 200,
550
- headers: {
551
- 'Transfer-Encoding': 'chunked',
552
- 'Content-Type': 'text/html; charset=UTF-8',
553
- 'Content-Encoding': 'Identity',
554
- ...(headers ?? {}),
555
- },
561
+ headers: mergedHeaders,
556
562
  ...restOptions,
557
563
  });
558
564
  }