@hyperspan/framework 1.0.24 → 1.0.26

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.
@@ -94,6 +94,8 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
94
94
  }
95
95
  }
96
96
 
97
+ activateScriptsIn(isFullDocument ? document.body : (target as ParentNode));
98
+
97
99
  opts.afterResponse && opts.afterResponse();
98
100
  lazyLoadScripts();
99
101
  }
@@ -126,13 +128,7 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
126
128
  return;
127
129
  }
128
130
 
129
- const content = await res.text();
130
- // No content = DO NOTHING (redirect or something else happened)
131
- if (!content) {
132
- return;
133
- }
134
-
135
- applyResponseHtml(content);
131
+ await consumeStreamingHtmlResponse(res, applyResponseHtml);
136
132
  })
137
133
  .catch((error) => {
138
134
  console.error('[Hyperspan] Error submitting form action:', error);
@@ -153,28 +149,60 @@ function splitInitialStreamHtml(html: string): { initial: string; streamTail: st
153
149
  };
154
150
  }
155
151
 
156
- /** Append streamed HTML to body and run any inline scripts (e.g. window._hsc.push). */
152
+ /** Clone a script node so the browser will execute it (preserves module type). */
153
+ function cloneScriptForExecution(script: HTMLScriptElement): HTMLScriptElement {
154
+ const executable = document.createElement('script');
155
+ if (script.src) {
156
+ executable.src = script.src;
157
+ } else if (script.textContent) {
158
+ executable.textContent = script.textContent;
159
+ }
160
+ if (script.type) {
161
+ executable.type = script.type;
162
+ }
163
+ for (const attr of script.getAttributeNames()) {
164
+ if (attr === 'src' || attr === 'type') {
165
+ continue;
166
+ }
167
+ executable.setAttribute(attr, script.getAttribute(attr) || '');
168
+ }
169
+ return executable;
170
+ }
171
+
172
+ /** Run scripts inserted by Idiomorph (innerHTML/morph does not execute them). */
173
+ function activateScriptsIn(root: ParentNode) {
174
+ root.querySelectorAll('script').forEach((script) => {
175
+ if (script.closest('template[id$="_content"]')) {
176
+ return;
177
+ }
178
+ script.replaceWith(cloneScriptForExecution(script));
179
+ });
180
+ }
181
+
182
+ /** Append streamed HTML to body and run top-level chunk scripts (e.g. window._hsc.push). */
157
183
  function appendHtmlToBody(html: string) {
158
184
  if (!html) {
159
185
  return;
160
186
  }
161
187
 
162
- const template = document.createElement('template');
163
- template.innerHTML = html;
188
+ const container = document.createElement('template');
189
+ container.innerHTML = html;
164
190
  const scripts: HTMLScriptElement[] = [];
165
- template.content.querySelectorAll('script').forEach((script) => {
166
- scripts.push(script);
191
+
192
+ for (const node of Array.from(container.content.childNodes)) {
193
+ if (node.nodeName === 'SCRIPT') {
194
+ scripts.push(node as HTMLScriptElement);
195
+ }
196
+ }
197
+
198
+ for (const script of scripts) {
167
199
  script.remove();
168
- });
169
- document.body.appendChild(template.content);
200
+ }
201
+
202
+ document.body.appendChild(container.content);
203
+
170
204
  for (const script of scripts) {
171
- const executable = document.createElement('script');
172
- if (script.src) {
173
- executable.src = script.src;
174
- } else {
175
- executable.textContent = script.textContent;
176
- }
177
- document.body.appendChild(executable);
205
+ document.body.appendChild(cloneScriptForExecution(script));
178
206
  }
179
207
  }
180
208
 
@@ -227,4 +255,4 @@ async function consumeStreamingHtmlResponse(
227
255
  }
228
256
  processChunk(decoder.decode(value, { stream: true }));
229
257
  }
230
- }
258
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,12 @@
1
- export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPResponseException } from './server';
2
- export type { Hyperspan } from './types';
1
+ export {
2
+ createConfig,
3
+ createContext,
4
+ createRoute,
5
+ createServer,
6
+ getRunnableRoute,
7
+ StreamResponse,
8
+ IS_PROD,
9
+ HTTPResponseException,
10
+ hyperspanDisableStreaming,
11
+ } from './server';
12
+ export type { Hyperspan } from './types';
@@ -1,5 +1,7 @@
1
- import { test, expect } from 'bun:test';
1
+ import { test, expect, describe } from 'bun:test';
2
2
  import { createRoute, createServer, createContext } from './server';
3
+ import { createAction } from './actions';
4
+ import { html, placeholder } from '@hyperspan/html';
3
5
  import type { Hyperspan as HS } from './types';
4
6
 
5
7
  test('route fetch() returns a Response', async () => {
@@ -53,7 +55,6 @@ test('server with two routes can return Response from one', async () => {
53
55
  return context.res.html('<h1>Posts Page</h1>');
54
56
  });
55
57
 
56
-
57
58
  // Test that we can get a Response from one of the routes
58
59
  const request = new Request('http://localhost:3000/users');
59
60
  const testRoute = server._routes.find((route: HS.Route) => route._path() === '/users');
@@ -80,7 +81,9 @@ test('server returns a route with a POST request', async () => {
80
81
  return context.res.html('<h1>POST /users</h1>');
81
82
  });
82
83
 
83
- const route = server._routes.find((route: HS.Route) => route._path() === '/users' && route._methods().includes('POST')) as HS.Route;
84
+ const route = server._routes.find(
85
+ (route: HS.Route) => route._path() === '/users' && route._methods().includes('POST')
86
+ ) as HS.Route;
84
87
  const request = new Request('http://localhost:3000/users', { method: 'POST' });
85
88
  const response = await route.fetch(request);
86
89
 
@@ -100,7 +103,9 @@ test('server returns a route with a ALL request', async () => {
100
103
  return context.res.html('<h1>ALL /users</h1>');
101
104
  });
102
105
 
103
- const route = server._routes.find((route: HS.Route) => route._path() === '/users' && route._methods().includes('*')) as HS.Route;
106
+ const route = server._routes.find(
107
+ (route: HS.Route) => route._path() === '/users' && route._methods().includes('*')
108
+ ) as HS.Route;
104
109
 
105
110
  // GET request
106
111
  let request = new Request('http://localhost:3000/users', { method: 'GET' });
@@ -144,7 +149,7 @@ test('createContext() can get and set cookies', () => {
144
149
  // Create a request with cookies in the Cookie header
145
150
  const request = new Request('http://localhost:3000/', {
146
151
  headers: {
147
- 'Cookie': 'sessionId=abc123; theme=dark; userId=42',
152
+ Cookie: 'sessionId=abc123; theme=dark; userId=42',
148
153
  },
149
154
  });
150
155
 
@@ -369,3 +374,184 @@ test('createContext() merge() function allows response headers to override conte
369
374
  expect(response.headers.get('X-Header')).toBe('response-value');
370
375
  expect(response.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
371
376
  });
377
+
378
+ describe('when streaming is disabled', () => {
379
+ test('placeholder resolves to real content in the initial response on the route', async () => {
380
+ const route = createRoute({
381
+ responseOptions: {
382
+ disableStreaming: () => true,
383
+ },
384
+ }).get(() => {
385
+ return html`<div>
386
+ ${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Real content</p>`))}
387
+ </div>`;
388
+ });
389
+
390
+ const request = new Request('http://localhost:3000/');
391
+ const response = await route.fetch(request);
392
+
393
+ expect(response).toBeInstanceOf(Response);
394
+ expect(response.status).toBe(200);
395
+ expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
396
+
397
+ const text = await response.text();
398
+ expect(text).toContain('<p>Real content</p>');
399
+ expect(text).not.toContain('Loading...');
400
+ expect(text).not.toContain('hs:loading');
401
+ });
402
+
403
+ test('placeholder resolves to real content in the initial response on the server', async () => {
404
+ const server = await createServer({
405
+ appDir: './app',
406
+ publicDir: './public',
407
+ plugins: [],
408
+ responseOptions: {
409
+ disableStreaming: () => true,
410
+ },
411
+ });
412
+
413
+ server.get('/', () => {
414
+ return html`<div>
415
+ ${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Real content</p>`))}
416
+ </div>`;
417
+ });
418
+
419
+ const route = server._routes[0];
420
+ route._serverConfig = server._config;
421
+
422
+ const request = new Request('http://localhost:3000/');
423
+ const response = await route.fetch(request);
424
+
425
+ expect(response).toBeInstanceOf(Response);
426
+ expect(response.status).toBe(200);
427
+ expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
428
+
429
+ const text = await response.text();
430
+ expect(text).toContain('<p>Real content</p>');
431
+ expect(text).not.toContain('Loading...');
432
+ expect(text).not.toContain('hs:loading');
433
+ });
434
+
435
+ test('disableStreaming receives hyperspanDisableStreaming for custom logic with framework fallback', async () => {
436
+ const route = createRoute({
437
+ responseOptions: {
438
+ disableStreaming(c, { hyperspanDisableStreaming }) {
439
+ if (c.req.query.get('no-stream') === '1') {
440
+ return true;
441
+ }
442
+ return hyperspanDisableStreaming(c);
443
+ },
444
+ },
445
+ }).get(() => {
446
+ return html`<div>
447
+ ${placeholder(html`<span>Loading...</span>`, Promise.resolve(html`<p>Real content</p>`))}
448
+ </div>`;
449
+ });
450
+
451
+ const normalRequest = new Request('http://localhost:3000/');
452
+ const normalResponse = await route.fetch(normalRequest);
453
+ expect(normalResponse.headers.get('Transfer-Encoding')).toBe('chunked');
454
+
455
+ const customRequest = new Request('http://localhost:3000/?no-stream=1');
456
+ const customResponse = await route.fetch(customRequest);
457
+ expect(customResponse.headers.get('Transfer-Encoding')).not.toBe('chunked');
458
+ expect(await customResponse.text()).toContain('<p>Real content</p>');
459
+
460
+ const botRequest = new Request('http://localhost:3000/', {
461
+ headers: { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' },
462
+ });
463
+ const botResponse = await route.fetch(botRequest);
464
+ expect(botResponse.headers.get('Transfer-Encoding')).not.toBe('chunked');
465
+ expect(await botResponse.text()).toContain('<p>Real content</p>');
466
+ });
467
+
468
+ test('nested promise and placeholder content fully resolves on initial load', async () => {
469
+ const route = createRoute({
470
+ responseOptions: {
471
+ disableStreaming: () => true,
472
+ },
473
+ }).get(async () => {
474
+ await new Promise((resolve) => setTimeout(resolve, 10));
475
+
476
+ const sectionPromise = (async () => {
477
+ await new Promise((resolve) => setTimeout(resolve, 10));
478
+ return placeholder(
479
+ html`<span>Outer placeholder</span>`,
480
+ (async () => {
481
+ await new Promise((resolve) => setTimeout(resolve, 10));
482
+ return html`<p>Inner resolved content</p>`;
483
+ })()
484
+ );
485
+ })();
486
+
487
+ return html`<main>
488
+ <h1>Static heading</h1>
489
+ ${sectionPromise}
490
+ </main>`;
491
+ });
492
+
493
+ const request = new Request('http://localhost:3000/');
494
+ const response = await route.fetch(request);
495
+
496
+ expect(response).toBeInstanceOf(Response);
497
+ expect(response.status).toBe(200);
498
+ expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
499
+
500
+ const text = await response.text();
501
+ expect(text).toContain('<h1>Static heading</h1>');
502
+ expect(text).toContain('<p>Inner resolved content</p>');
503
+ expect(text).not.toContain('Outer placeholder');
504
+ expect(text).not.toContain('hs:loading');
505
+ });
506
+
507
+ test('nested promise and async action form fully resolve on initial load', async () => {
508
+ const action = createAction({ name: 'nested-async-form-action' })
509
+ .form(async () => {
510
+ await new Promise((resolve) => setTimeout(resolve, 10));
511
+ return html`<form>
512
+ <p>Action form content</p>
513
+ <button type="submit">Submit</button>
514
+ </form>`;
515
+ })
516
+ .post(async () => html`<p>Submitted</p>`);
517
+
518
+ const route = createRoute({
519
+ responseOptions: {
520
+ disableStreaming: () => true,
521
+ },
522
+ }).get(async (c: HS.Context) => {
523
+ await new Promise((resolve) => setTimeout(resolve, 10));
524
+
525
+ const sectionPromise = (async () => {
526
+ await new Promise((resolve) => setTimeout(resolve, 10));
527
+ return placeholder(
528
+ html`<span>Outer placeholder</span>`,
529
+ (async () => {
530
+ await new Promise((resolve) => setTimeout(resolve, 10));
531
+ return action.render(c);
532
+ })()
533
+ );
534
+ })();
535
+
536
+ return html`<main>
537
+ <h1>Static heading</h1>
538
+ ${sectionPromise}
539
+ </main>`;
540
+ });
541
+
542
+ const request = new Request('http://localhost:3000/');
543
+ const response = await route.fetch(request);
544
+
545
+ expect(response).toBeInstanceOf(Response);
546
+ expect(response.status).toBe(200);
547
+ expect(response.headers.get('Transfer-Encoding')).not.toBe('chunked');
548
+
549
+ const text = await response.text();
550
+ expect(text).toContain('<h1>Static heading</h1>');
551
+ expect(text).toContain('<hs-action');
552
+ expect(text).toContain('<p>Action form content</p>');
553
+ expect(text).toContain('type="submit"');
554
+ expect(text).not.toContain('Outer placeholder');
555
+ expect(text).not.toContain('hs:loading');
556
+ });
557
+ });
package/src/server.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import './ssr/install-server-dom-mock';
2
- import { HSHtml, html, isHSHtml, renderStream, renderAsync, render, _typeOf } from '@hyperspan/html';
2
+ import {
3
+ HSHtml,
4
+ html,
5
+ isHSHtml,
6
+ renderStream,
7
+ renderAsync,
8
+ render,
9
+ _typeOf,
10
+ } from '@hyperspan/html';
3
11
  import { isbot } from 'isbot';
4
12
  import { executeMiddleware } from './middleware';
5
13
  import { parsePath, removeUndefined } from './utils';
@@ -9,6 +17,22 @@ import type { Hyperspan as HS } from './types';
9
17
 
10
18
  export const IS_PROD = process.env.NODE_ENV === 'production';
11
19
 
20
+ /**
21
+ * Default streaming disable detection (bots receive fully rendered HTML).
22
+ */
23
+ export function hyperspanDisableStreaming(context: HS.Context): boolean {
24
+ return isbot(context.req.raw.headers.get('user-agent') ?? '');
25
+ }
26
+
27
+ const disableStreamingOptions = { hyperspanDisableStreaming };
28
+
29
+ function shouldDisableStreaming(
30
+ disableStreaming: HS.DisableStreamingFn | undefined,
31
+ context: HS.Context
32
+ ): boolean {
33
+ return disableStreaming?.(context, disableStreamingOptions) ?? false;
34
+ }
35
+
12
36
  export class HTTPResponseException extends Error {
13
37
  public _error?: Error;
14
38
  public _response?: Response;
@@ -28,8 +52,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
28
52
  publicDir: './public',
29
53
  plugins: [],
30
54
  responseOptions: {
31
- // Disable streaming for bots by default
32
- disableStreaming: (c) => isbot(c.req.raw.headers.get('user-agent') ?? ''),
55
+ disableStreaming: hyperspanDisableStreaming,
33
56
  },
34
57
  };
35
58
  return {
@@ -51,11 +74,15 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
51
74
  const method = req.method.toUpperCase();
52
75
  const headers = new Headers(req.headers);
53
76
  const path = route?._path() || '/';
54
- // @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
55
- const params: HS.RouteParamsParser<path> & Record<string, string | undefined> = Object.assign({}, req?.params || {}, removeUndefined(route?._config.params || {}));
77
+ const requestParams = (req as Request & { params?: Record<string, string | undefined> }).params;
78
+ const params: Record<string, string | undefined> = Object.assign(
79
+ {},
80
+ requestParams || {},
81
+ removeUndefined(route?._config.params || {})
82
+ );
56
83
 
57
84
  // Replace catch-all param with the value from the URL path
58
- const catchAllParam = Object.keys(params).find(key => key.startsWith('...'));
85
+ const catchAllParam = Object.keys(params).find((key) => key.startsWith('...'));
59
86
  if (catchAllParam && path.includes('/*')) {
60
87
  const catchAllValue = url.pathname.split(path.replace('/*', '/')).pop();
61
88
  params[catchAllParam.replace('...', '')] = catchAllValue;
@@ -84,7 +111,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
84
111
  name: route?._config.name || undefined,
85
112
  path,
86
113
  params: params,
87
- cssImports: route ? route._config.cssImports ?? [] : [],
114
+ cssImports: route ? (route._config.cssImports ?? []) : [],
88
115
  },
89
116
  req: {
90
117
  raw: req,
@@ -93,21 +120,50 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
93
120
  headers,
94
121
  query,
95
122
  cookies: new Cookies(req),
96
- async text() { return req.clone().text() },
97
- async json<T = unknown>() { return await req.clone().json() as T },
98
- async formData<T = unknown>() { return await req.clone().formData() as T },
99
- async urlencoded() { return new URLSearchParams(await req.clone().text()) },
123
+ async text() {
124
+ return req.clone().text();
125
+ },
126
+ async json<T = unknown>() {
127
+ return (await req.clone().json()) as T;
128
+ },
129
+ async formData<T = unknown>() {
130
+ return (await req.clone().formData()) as T;
131
+ },
132
+ async urlencoded() {
133
+ return new URLSearchParams(await req.clone().text());
134
+ },
100
135
  },
101
136
  res: {
102
137
  cookies: new Cookies(req, headers),
103
138
  headers,
104
139
  status,
105
- html: (html: string, options?: ResponseInit) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
106
- json: (json: any, options?: ResponseInit) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
107
- text: (text: string, options?: ResponseInit) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
108
- redirect: (url: string, options?: ResponseInit) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
109
- error: (error: Error, options?: ResponseInit) => merge(new Response(error.message, { status: 500, ...options })),
110
- notFound: (options?: ResponseInit) => merge(new Response('Not Found', { status: 404, ...options })),
140
+ html: (html: string, options?: ResponseInit) =>
141
+ merge(
142
+ new Response(html, {
143
+ ...options,
144
+ headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers },
145
+ })
146
+ ),
147
+ json: (json: any, options?: ResponseInit) =>
148
+ merge(
149
+ new Response(JSON.stringify(json), {
150
+ ...options,
151
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
152
+ })
153
+ ),
154
+ text: (text: string, options?: ResponseInit) =>
155
+ merge(
156
+ new Response(text, {
157
+ ...options,
158
+ headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers },
159
+ })
160
+ ),
161
+ redirect: (url: string, options?: ResponseInit) =>
162
+ merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
163
+ error: (error: Error, options?: ResponseInit) =>
164
+ merge(new Response(error.message, { status: 500, ...options })),
165
+ notFound: (options?: ResponseInit) =>
166
+ merge(new Response('Not Found', { status: 404, ...options })),
111
167
  merge,
112
168
  },
113
169
  };
@@ -137,7 +193,16 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
137
193
  const api: HS.Route = {
138
194
  _kind: 'hsRoute',
139
195
  _config: config,
140
- _middleware: { GET: [], POST: [], PUT: [], PATCH: [], DELETE: [], HEAD: [], OPTIONS: [], '*': [] },
196
+ _middleware: {
197
+ GET: [],
198
+ POST: [],
199
+ PUT: [],
200
+ PATCH: [],
201
+ DELETE: [],
202
+ HEAD: [],
203
+ OPTIONS: [],
204
+ '*': [],
205
+ },
141
206
  _methods: () => Object.keys(_handlers),
142
207
  _path() {
143
208
  if (this._config.path) {
@@ -215,7 +280,7 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
215
280
  */
216
281
  use(middleware: HS.MiddlewareFunction, opts: HS.MiddlewareMethodOptions = {}) {
217
282
  if (opts?.methods) {
218
- opts.methods.forEach(method => {
283
+ opts.methods.forEach((method) => {
219
284
  api._middleware[method].push(middleware);
220
285
  });
221
286
  } else {
@@ -229,7 +294,7 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
229
294
  */
230
295
  middleware(middleware: Array<HS.MiddlewareFunction>, opts: HS.MiddlewareMethodOptions = {}) {
231
296
  if (opts?.methods) {
232
- opts.methods.forEach(method => {
297
+ opts.methods.forEach((method) => {
233
298
  api._middleware[method] = middleware;
234
299
  });
235
300
  } else {
@@ -252,25 +317,24 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
252
317
  if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
253
318
  return context.res.html(
254
319
  render(html`
255
- <!DOCTYPE html>
256
- <html lang="en"></html>
257
- `),
320
+ <!DOCTYPE html>
321
+ <html lang="en"></html>
322
+ `),
258
323
  {
259
324
  status: 200,
260
325
  headers: {
261
326
  'Access-Control-Allow-Origin': '*',
262
- 'Access-Control-Allow-Methods': [
263
- 'HEAD',
264
- 'OPTIONS',
265
- ...Object.keys(_handlers),
266
- ].join(', '),
327
+ 'Access-Control-Allow-Methods': ['HEAD', 'OPTIONS', ...Object.keys(_handlers)].join(
328
+ ', '
329
+ ),
267
330
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
268
331
  },
269
332
  }
270
333
  );
271
334
  }
272
335
 
273
- const handler = (method === 'HEAD' ? _handlers['GET'] : _handlers[method]) ?? _handlers['*'];
336
+ const handler =
337
+ (method === 'HEAD' ? _handlers['GET'] : _handlers[method]) ?? _handlers['*'];
274
338
 
275
339
  if (!handler) {
276
340
  return context.res.error(new Error('Method not allowed'), { status: 405 });
@@ -296,7 +360,10 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
296
360
 
297
361
  if (isHTMLContent(routeContent)) {
298
362
  // Merge server and route-specific response options
299
- const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
363
+ const responseOptions = {
364
+ ...(api._serverConfig?.responseOptions ?? {}),
365
+ ...(api._config?.responseOptions ?? {}),
366
+ };
300
367
  return returnHTMLResponse(context, () => routeContent, responseOptions);
301
368
  }
302
369
 
@@ -324,11 +391,22 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
324
391
  // Run the route handler and any middleware
325
392
  // If an error occurs, run the error handler if it exists
326
393
  try {
327
- return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
394
+ return await executeMiddleware(context, [
395
+ ...globalMiddleware,
396
+ ...methodMiddleware,
397
+ methodHandler,
398
+ ]);
328
399
  } catch (e) {
329
400
  if (_errorHandler !== undefined) {
330
- const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
331
- return returnHTMLResponse(context, () => (_errorHandler as HS.ErrorHandler)(context, e as Error), responseOptions);
401
+ const responseOptions = {
402
+ ...(api._serverConfig?.responseOptions ?? {}),
403
+ ...(api._config?.responseOptions ?? {}),
404
+ };
405
+ return returnHTMLResponse(
406
+ context,
407
+ () => (_errorHandler as HS.ErrorHandler)(context, e as Error),
408
+ responseOptions
409
+ );
332
410
  }
333
411
  throw e;
334
412
  }
@@ -346,16 +424,25 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
346
424
 
347
425
  // Load plugins, if any
348
426
  if (config.plugins && config.plugins.length > 0) {
349
- await Promise.all(config.plugins.map(plugin => plugin(config)));
427
+ await Promise.all(config.plugins.map((plugin) => plugin(config)));
350
428
  }
351
429
 
352
430
  const api: HS.Server = {
353
431
  _config: config,
354
432
  _routes: _routes,
355
- _middleware: { GET: [], POST: [], PUT: [], PATCH: [], DELETE: [], HEAD: [], OPTIONS: [], '*': [] },
433
+ _middleware: {
434
+ GET: [],
435
+ POST: [],
436
+ PUT: [],
437
+ PATCH: [],
438
+ DELETE: [],
439
+ HEAD: [],
440
+ OPTIONS: [],
441
+ '*': [],
442
+ },
356
443
  use(middleware: HS.MiddlewareFunction, opts?: HS.MiddlewareMethodOptions) {
357
444
  if (opts?.methods) {
358
- opts.methods.forEach(method => {
445
+ opts.methods.forEach((method) => {
359
446
  api._middleware[method].push(middleware);
360
447
  });
361
448
  } else {
@@ -414,7 +501,8 @@ export async function createServer(config: HS.Config = {} as HS.Config): Promise
414
501
  * Checks if a response is HTML content
415
502
  */
416
503
  function isHTMLContent(response: unknown): response is Response {
417
- const hasHTMLContentType = response instanceof Response && response.headers.get('Content-Type') === 'text/html';
504
+ const hasHTMLContentType =
505
+ response instanceof Response && response.headers.get('Content-Type') === 'text/html';
418
506
  const isHTMLTemplate = isHSHtml(response);
419
507
  const isHTMLString = typeof response === 'string' && response.trim().startsWith('<');
420
508
 
@@ -427,7 +515,11 @@ function isHTMLContent(response: unknown): response is Response {
427
515
  export async function returnHTMLResponse(
428
516
  context: HS.Context,
429
517
  handlerFn: () => unknown,
430
- responseOptions?: { status?: number; headers?: Record<string, string>; disableStreaming?: (context: HS.Context) => boolean }
518
+ responseOptions?: {
519
+ status?: number;
520
+ headers?: Record<string, string>;
521
+ disableStreaming?: HS.DisableStreamingFn;
522
+ }
431
523
  ): Promise<Response> {
432
524
  try {
433
525
  const routeContent = await handlerFn();
@@ -439,7 +531,7 @@ export async function returnHTMLResponse(
439
531
 
440
532
  // Render HSHtml if returned from route handler
441
533
  if (isHSHtml(routeContent)) {
442
- const disableStreaming = responseOptions?.disableStreaming?.(context) ?? false;
534
+ const disableStreaming = shouldDisableStreaming(responseOptions?.disableStreaming, context);
443
535
 
444
536
  // Stream only if enabled and there is async content to stream
445
537
  if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
@@ -447,13 +539,13 @@ export async function returnHTMLResponse(
447
539
  renderStream(routeContent as HSHtml, {
448
540
  renderChunk: (chunk) => {
449
541
  return html`
450
- <template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
451
- <script>
452
- window._hsc = window._hsc || [];
453
- window._hsc.push({id: "${chunk.id}" });
454
- </script>
455
- `;
456
- }
542
+ <template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
543
+ <script>
544
+ window._hsc = window._hsc || [];
545
+ window._hsc.push({ id: '${chunk.id}' });
546
+ </script>
547
+ `;
548
+ },
457
549
  }),
458
550
  responseOptions
459
551
  ) as Response;
@@ -559,7 +651,6 @@ async function showErrorReponse(
559
651
  return context.res.html(output, Object.assign({ status }, responseOptions));
560
652
  }
561
653
 
562
-
563
654
  /**
564
655
  * Streaming response: chunked transfer with Transfer-Encoding and Content-Encoding.
565
656
  * Pass an async iterator (HTML chunks) or a ReadableStream; default Content-Type is HTML for iterators