@hyperspan/framework 0.3.3 → 0.3.4

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/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ assetHash,
2
3
  buildClientCSS,
3
4
  buildClientJS
4
5
  } from "./assets.js";
@@ -1857,34 +1858,13 @@ function createRoute(handler) {
1857
1858
  ..._middleware,
1858
1859
  async (context) => {
1859
1860
  const method = context.req.method.toUpperCase();
1860
- try {
1861
+ return returnHTMLResponse(context, () => {
1861
1862
  const handler2 = _handlers[method];
1862
1863
  if (!handler2) {
1863
1864
  throw new HTTPException(405, { message: "Method not allowed" });
1864
1865
  }
1865
- const routeContent = await handler2(context);
1866
- if (routeContent instanceof Response) {
1867
- return routeContent;
1868
- }
1869
- const userIsBot = isbot(context.req.header("User-Agent"));
1870
- const streamOpt = context.req.query("__nostream");
1871
- const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
1872
- if (isHSHtml(routeContent)) {
1873
- if (streamingEnabled && routeContent.asyncContent?.length > 0) {
1874
- return new StreamResponse(renderStream(routeContent));
1875
- } else {
1876
- const output = await renderAsync(routeContent);
1877
- return context.html(output);
1878
- }
1879
- }
1880
- if (routeContent instanceof Response) {
1881
- return routeContent;
1882
- }
1883
- return context.text(String(routeContent));
1884
- } catch (e) {
1885
- !IS_PROD && console.error(e);
1886
- return await showErrorReponse(context, e);
1887
- }
1866
+ return handler2(context);
1867
+ });
1888
1868
  }
1889
1869
  ];
1890
1870
  }
@@ -1963,6 +1943,29 @@ function createAPIRoute(handler) {
1963
1943
  };
1964
1944
  return api;
1965
1945
  }
1946
+ async function returnHTMLResponse(context, handlerFn, responseOptions) {
1947
+ try {
1948
+ const routeContent = await handlerFn();
1949
+ if (routeContent instanceof Response) {
1950
+ return routeContent;
1951
+ }
1952
+ const userIsBot = isbot(context.req.header("User-Agent"));
1953
+ const streamOpt = context.req.query("__nostream");
1954
+ const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
1955
+ if (isHSHtml(routeContent)) {
1956
+ if (streamingEnabled && routeContent.asyncContent?.length > 0) {
1957
+ return new StreamResponse(renderStream(routeContent), responseOptions);
1958
+ } else {
1959
+ const output = await renderAsync(routeContent);
1960
+ return context.html(output, responseOptions);
1961
+ }
1962
+ }
1963
+ return context.html(String(routeContent), responseOptions);
1964
+ } catch (e) {
1965
+ !IS_PROD && console.error(e);
1966
+ return await showErrorReponse(context, e, responseOptions);
1967
+ }
1968
+ }
1966
1969
  function getRunnableRoute(route) {
1967
1970
  if (isRunnableRoute(route)) {
1968
1971
  return route;
@@ -1984,7 +1987,7 @@ function isRunnableRoute(route) {
1984
1987
  const runnableKind = ["hsRoute", "hsAPIRoute", "hsAction"].includes(obj?._kind);
1985
1988
  return runnableKind && "_getRouteHandlers" in obj;
1986
1989
  }
1987
- async function showErrorReponse(context, err) {
1990
+ async function showErrorReponse(context, err, responseOptions) {
1988
1991
  let status = 500;
1989
1992
  const message = err.message || "Internal Server Error";
1990
1993
  if (err instanceof HTTPException) {
@@ -1993,6 +1996,16 @@ async function showErrorReponse(context, err) {
1993
1996
  const stack = !IS_PROD && err.stack ? err.stack.split(`
1994
1997
  `).slice(1).join(`
1995
1998
  `) : "";
1999
+ if (context.req.header("X-Request-Type") === "partial") {
2000
+ const output2 = render(html`
2001
+ <section style="padding: 20px;">
2002
+ <p style="margin-bottom: 10px;"><strong>Error</strong></p>
2003
+ <strong>${message}</strong>
2004
+ ${stack ? html`<pre>${stack}</pre>` : ""}
2005
+ </section>
2006
+ `);
2007
+ return context.html(output2, Object.assign({ status }, responseOptions));
2008
+ }
1996
2009
  const output = render(html`
1997
2010
  <!DOCTYPE html>
1998
2011
  <html lang="en">
@@ -2010,7 +2023,7 @@ async function showErrorReponse(context, err) {
2010
2023
  </body>
2011
2024
  </html>
2012
2025
  `);
2013
- return context.html(output, { status });
2026
+ return context.html(output, Object.assign({ status }, responseOptions));
2014
2027
  }
2015
2028
  var ROUTE_SEGMENT = /(\[[a-zA-Z_\.]+\])/g;
2016
2029
  async function buildRoutes(config) {
@@ -2042,11 +2055,35 @@ async function buildRoutes(config) {
2042
2055
  }
2043
2056
  routes.push({
2044
2057
  file: join("./", routesDir, file),
2045
- route: route || "/",
2058
+ route: normalizePath(route || "/"),
2046
2059
  params
2047
2060
  });
2048
2061
  }
2049
- return routes;
2062
+ return await Promise.all(routes.map(async (route) => {
2063
+ route.module = (await import(join(CWD, route.file))).default;
2064
+ return route;
2065
+ }));
2066
+ }
2067
+ async function buildActions(config) {
2068
+ const routesDir = join(config.appDir, "actions");
2069
+ const files = await readdir(routesDir, { recursive: true });
2070
+ const routes = [];
2071
+ for (const file of files) {
2072
+ if (!file.includes(".") || basename(file).startsWith(".")) {
2073
+ continue;
2074
+ }
2075
+ let route = assetHash("/" + file.replace(extname(file), ""));
2076
+ routes.push({
2077
+ file: join("./", routesDir, file),
2078
+ route: `/__actions/${route}`,
2079
+ params: []
2080
+ });
2081
+ }
2082
+ return await Promise.all(routes.map(async (route) => {
2083
+ route.module = (await import(join(CWD, route.file))).default;
2084
+ route.route = route.module._route;
2085
+ return route;
2086
+ }));
2050
2087
  }
2051
2088
  function createRouteFromModule(RouteModule) {
2052
2089
  const route = getRunnableRoute(RouteModule);
@@ -2056,15 +2093,17 @@ async function createServer(config) {
2056
2093
  await Promise.all([buildClientJS(), buildClientCSS()]);
2057
2094
  const app = new Hono2;
2058
2095
  config.beforeRoutesAdded && config.beforeRoutesAdded(app);
2059
- const fileRoutes = await buildRoutes(config);
2096
+ const [routes, actions] = await Promise.all([buildRoutes(config), buildActions(config)]);
2097
+ const fileRoutes = routes.concat(actions);
2060
2098
  const routeMap = [];
2061
2099
  for (let i = 0;i < fileRoutes.length; i++) {
2062
2100
  let route = fileRoutes[i];
2063
- const fullRouteFile = join(CWD, route.file);
2064
- const routePattern = normalizePath(route.route);
2065
- routeMap.push({ route: routePattern, file: route.file });
2066
- const routeHandlers = createRouteFromModule(await import(fullRouteFile));
2067
- app.all(routePattern, ...routeHandlers);
2101
+ routeMap.push({ route: route.route, file: route.file });
2102
+ if (!route.module) {
2103
+ throw new Error(`Route module not loaded! File: ${route.file}`);
2104
+ }
2105
+ const routeHandlers = createRouteFromModule(route.module);
2106
+ app.all(route.route, ...routeHandlers);
2068
2107
  }
2069
2108
  if (routeMap.length === 0) {
2070
2109
  app.get("/", (context) => {
@@ -2123,6 +2162,7 @@ function normalizePath(urlPath) {
2123
2162
  return (urlPath.endsWith("/") ? urlPath.substring(0, urlPath.length - 1) : urlPath).toLowerCase() || "/";
2124
2163
  }
2125
2164
  export {
2165
+ returnHTMLResponse,
2126
2166
  normalizePath,
2127
2167
  isRunnableRoute,
2128
2168
  getRunnableRoute,
@@ -2133,6 +2173,7 @@ export {
2133
2173
  createConfig,
2134
2174
  createAPIRoute,
2135
2175
  buildRoutes,
2176
+ buildActions,
2136
2177
  StreamResponse,
2137
2178
  IS_PROD
2138
2179
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "dist/server.ts",
6
6
  "types": "src/server.ts",
@@ -45,6 +45,7 @@ describe('createAction', () => {
45
45
  // Mock context to run action
46
46
  const mockContext = {
47
47
  req: {
48
+ method: 'POST',
48
49
  formData: async () => {
49
50
  const formData = new FormData();
50
51
  formData.append('name', 'John');
@@ -53,7 +54,7 @@ describe('createAction', () => {
53
54
  },
54
55
  } as Context;
55
56
 
56
- const response = await action.run('POST', mockContext);
57
+ const response = await action.run(mockContext);
57
58
 
58
59
  const formResponse = render(response as HSHtml);
59
60
  expect(formResponse).toContain('Thanks for submitting the form, John!');
@@ -77,6 +78,7 @@ describe('createAction', () => {
77
78
  // Mock context to run action
78
79
  const mockContext = {
79
80
  req: {
81
+ method: 'POST',
80
82
  formData: async () => {
81
83
  const formData = new FormData();
82
84
  formData.append('name', ''); // No name = error
@@ -85,7 +87,7 @@ describe('createAction', () => {
85
87
  },
86
88
  } as Context;
87
89
 
88
- const response = await action.run('POST', mockContext);
90
+ const response = await action.run(mockContext);
89
91
 
90
92
  const formResponse = render(response as HSHtml);
91
93
  expect(formResponse).toContain('There was an error!');
package/src/actions.ts CHANGED
@@ -2,51 +2,80 @@ import { html, HSHtml } from '@hyperspan/html';
2
2
  import * as z from 'zod/v4';
3
3
  import { HTTPException } from 'hono/http-exception';
4
4
 
5
- import type { THSResponseTypes } from './server';
6
- import type { Context } from 'hono';
5
+ import { IS_PROD, returnHTMLResponse, type THSResponseTypes } from './server';
6
+ import type { Context, MiddlewareHandler } from 'hono';
7
+ import type { HandlerResponse, Next, TypedResponse } from 'hono/types';
8
+ import { assetHash } from './assets';
7
9
 
8
10
  /**
9
11
  * Actions = Form + route handler
10
12
  * Automatically handles and parses form data
11
13
  *
12
- * INITIAL IDEA OF HOW THIS WILL WORK:
14
+ * HOW THIS WORKS:
13
15
  * ---
14
- * 1. Renders component as initial form markup for GET request
15
- * 2. Bind form onSubmit function to custom client JS handling
16
- * 3. Submits form with JavaScript fetch()
17
- * 4. Replaces form content with content from server
18
- * 5. All validation and save logic is on the server
19
- * 6. Handles any Exception thrown on server as error displayed in client
16
+ * 1. Renders in any template as initial form markup with action.render()
17
+ * 2. Binds form onSubmit function to custom client JS handling via <hs-action> web component
18
+ * 3. Submits form with JavaScript fetch() + FormData as normal POST form submission
19
+ * 4. All validation and save logic is run on the server
20
+ * 5. Replaces form content in place with HTML response content from server via the Idiomorph library
21
+ * 6. Handles any Exception thrown on server as error displayed back to user on the page
20
22
  */
23
+ type TActionResponse = THSResponseTypes | HandlerResponse<any> | TypedResponse<any, any, any>;
21
24
  export interface HSAction<T extends z.ZodTypeAny> {
22
25
  _kind: string;
23
- form(renderForm: ({ data }: { data?: z.infer<T> }) => HSHtml): HSAction<T>;
24
- post(handler: (c: Context, { data }: { data?: z.infer<T> }) => THSResponseTypes): HSAction<T>;
26
+ _route: string;
27
+ _form: Parameters<HSAction<T>['form']>[0];
28
+ form(
29
+ renderForm: ({ data, error }: { data?: z.infer<T>; error?: z.ZodError | Error }) => HSHtml
30
+ ): HSAction<T>;
31
+ post(
32
+ handler: (
33
+ c: Context<any, any, {}>,
34
+ { data }: { data?: z.infer<T> }
35
+ ) => TActionResponse | Promise<TActionResponse>
36
+ ): HSAction<T>;
25
37
  error(
26
38
  handler: (
27
- c: Context,
39
+ c: Context<any, any, {}>,
28
40
  { data, error }: { data?: z.infer<T>; error?: z.ZodError | Error }
29
- ) => THSResponseTypes
41
+ ) => TActionResponse
30
42
  ): HSAction<T>;
31
- render(props?: { data?: z.infer<T>; error?: z.ZodError | Error }): THSResponseTypes;
32
- run(method: 'GET' | 'POST', c: Context): Promise<THSResponseTypes>;
43
+ render(props?: { data?: z.infer<T>; error?: z.ZodError | Error }): TActionResponse;
44
+ run(c: Context<any, any, {}>): TActionResponse | Promise<TActionResponse>;
45
+ middleware: (
46
+ middleware: Array<
47
+ | MiddlewareHandler
48
+ | ((context: Context<any, string, {}>) => TActionResponse | Promise<TActionResponse>)
49
+ >
50
+ ) => HSAction<T>;
51
+ _getRouteHandlers: () => Array<
52
+ | MiddlewareHandler
53
+ | ((context: Context, next: Next) => TActionResponse | Promise<TActionResponse>)
54
+ | ((context: Context) => TActionResponse | Promise<TActionResponse>)
55
+ >;
33
56
  }
34
57
 
35
58
  export function unstable__createAction<T extends z.ZodTypeAny>(
36
59
  schema: T | null = null,
37
- form: Parameters<HSAction<T>['form']>[0] | null = null
60
+ form: Parameters<HSAction<T>['form']>[0]
38
61
  ) {
39
62
  let _handler: Parameters<HSAction<T>['post']>[0] | null = null,
40
- _form: Parameters<HSAction<T>['form']>[0] | null = form,
41
- _errorHandler: Parameters<HSAction<T>['error']>[0] | null = null;
63
+ _form: Parameters<HSAction<T>['form']>[0] = form,
64
+ _errorHandler: Parameters<HSAction<T>['error']>[0] | null = null,
65
+ _middleware: Array<
66
+ | MiddlewareHandler
67
+ | ((context: Context, next: Next) => TActionResponse | Promise<TActionResponse>)
68
+ | ((context: Context) => TActionResponse | Promise<TActionResponse>)
69
+ > = [];
42
70
 
43
71
  const api: HSAction<T> = {
44
72
  _kind: 'hsAction',
73
+ _route: `/__actions/${assetHash(_form.toString())}`,
74
+ _form,
45
75
  form(renderForm) {
46
76
  _form = renderForm;
47
77
  return api;
48
78
  },
49
-
50
79
  /**
51
80
  * Process form data
52
81
  *
@@ -57,18 +86,44 @@ export function unstable__createAction<T extends z.ZodTypeAny>(
57
86
  _handler = handler;
58
87
  return api;
59
88
  },
60
-
89
+ /**
90
+ * Cusotm error handler if you want to display something other than the default
91
+ */
61
92
  error(handler) {
62
93
  _errorHandler = handler;
63
94
  return api;
64
95
  },
65
-
96
+ /**
97
+ * Add middleware specific to this route
98
+ */
99
+ middleware(middleware) {
100
+ _middleware = middleware;
101
+ return api;
102
+ },
66
103
  /**
67
104
  * Get form renderer method
68
105
  */
69
106
  render(formState?: { data?: z.infer<T>; error?: z.ZodError | Error }) {
70
107
  const form = _form ? _form(formState || {}) : null;
71
- return form ? html`<hs-action>${form}</hs-action>` : null;
108
+ return form ? html`<hs-action url="${this._route}">${form}</hs-action>` : null;
109
+ },
110
+
111
+ _getRouteHandlers() {
112
+ return [
113
+ ..._middleware,
114
+ async (c: Context) => {
115
+ const response = await returnHTMLResponse(c, () => api.run(c));
116
+
117
+ // Replace redirects with special header because fetch() automatically follows redirects
118
+ // and we want to redirect the user to the actual full page instead
119
+ if ([301, 302, 307, 308].includes(response.status)) {
120
+ response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
121
+ response.headers.delete('Location');
122
+ }
123
+
124
+ return response;
125
+ },
126
+ ];
72
127
  },
73
128
 
74
129
  /**
@@ -77,9 +132,11 @@ export function unstable__createAction<T extends z.ZodTypeAny>(
77
132
  * Returns result from form processing if successful
78
133
  * Re-renders form with data and error information otherwise
79
134
  */
80
- async run(method: 'GET' | 'POST', c: Context) {
135
+ async run(c) {
136
+ const method = c.req.method;
137
+
81
138
  if (method === 'GET') {
82
- return api.render();
139
+ return await api.render();
83
140
  }
84
141
 
85
142
  if (method !== 'POST') {
@@ -89,7 +146,7 @@ export function unstable__createAction<T extends z.ZodTypeAny>(
89
146
  const formData = await c.req.formData();
90
147
  const jsonData = unstable__formDataToJSON(formData);
91
148
  const schemaData = schema ? schema.safeParse(jsonData) : null;
92
- const data = schemaData?.success ? (schemaData.data as z.infer<T>) : undefined;
149
+ const data = schemaData?.success ? (schemaData.data as z.infer<T>) : jsonData;
93
150
  let error: z.ZodError | Error | null = null;
94
151
 
95
152
  try {
@@ -101,16 +158,20 @@ export function unstable__createAction<T extends z.ZodTypeAny>(
101
158
  throw new Error('Action POST handler not set! Every action must have a POST handler.');
102
159
  }
103
160
 
104
- return _handler(c, { data });
161
+ return await _handler(c, { data });
105
162
  } catch (e) {
106
163
  error = e as Error | z.ZodError;
164
+ !IS_PROD && console.error(error);
107
165
  }
108
166
 
109
167
  if (error && _errorHandler) {
110
- return _errorHandler(c, { data, error });
168
+ // @ts-ignore
169
+ return await returnHTMLResponse(c, () => _errorHandler(c, { data, error }), {
170
+ status: 400,
171
+ });
111
172
  }
112
173
 
113
- return api.render({ data, error });
174
+ return await returnHTMLResponse(c, () => api.render({ data, error }), { status: 400 });
114
175
  },
115
176
  };
116
177
 
@@ -72,15 +72,22 @@ class HSAction extends HTMLElement {
72
72
  super();
73
73
  }
74
74
 
75
- // Element is mounted in the DOM
76
75
  connectedCallback() {
77
- const form = this.querySelector('form');
78
-
79
- if (form) {
80
- form.addEventListener('submit', (e) => {
81
- formSubmitToRoute(e, form as HTMLFormElement);
82
- });
83
- }
76
+ // Have to run this code AFTER it is added to the DOM...
77
+ setTimeout(() => {
78
+ const form = this.querySelector('form');
79
+
80
+ if (form) {
81
+ form.setAttribute('action', this.getAttribute('url') || '');
82
+ const submitHandler = (e: Event) => {
83
+ formSubmitToRoute(e, form as HTMLFormElement, {
84
+ afterResponse: () => this.connectedCallback(),
85
+ });
86
+ form.removeEventListener('submit', submitHandler);
87
+ };
88
+ form.addEventListener('submit', submitHandler);
89
+ }
90
+ });
84
91
  }
85
92
  }
86
93
  window.customElements.define('hs-action', HSAction);
@@ -88,24 +95,32 @@ window.customElements.define('hs-action', HSAction);
88
95
  /**
89
96
  * Submit form data to route and replace contents with response
90
97
  */
91
- function formSubmitToRoute(e: Event, form: HTMLFormElement) {
98
+ type TFormSubmitOptons = { afterResponse: () => any };
99
+ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
92
100
  e.preventDefault();
93
101
 
94
102
  const formUrl = form.getAttribute('action') || '';
95
103
  const formData = new FormData(form);
96
104
  const method = form.getAttribute('method')?.toUpperCase() || 'POST';
105
+ const headers = {
106
+ Accept: 'text/html',
107
+ 'X-Request-Type': 'partial',
108
+ };
97
109
 
98
110
  let response: Response;
99
111
 
100
- fetch(formUrl, { body: formData, method })
101
- .then((res: Response) => {
102
- // @TODO: Handle redirects with some custom server thing?
103
- // This... actually won't work, because fetch automatically follows all redirects (a 3xx response will never be returned to the client)
104
- const isRedirect = [301, 302].includes(res.status);
112
+ const hsActionTag = form.closest('hs-action');
113
+ const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
114
+ if (submitBtn) {
115
+ submitBtn.setAttribute('disabled', 'disabled');
116
+ }
105
117
 
106
- // Is response a redirect? If so, let's follow it in the client!
107
- if (isRedirect) {
108
- const newUrl = res.headers.get('Location');
118
+ fetch(formUrl, { body: formData, method, headers })
119
+ .then((res: Response) => {
120
+ // Look for special header that indicates a redirect.
121
+ // fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
122
+ if (res.headers.has('X-Redirect-Location')) {
123
+ const newUrl = res.headers.get('X-Redirect-Location');
109
124
  if (newUrl) {
110
125
  window.location.assign(newUrl);
111
126
  }
@@ -121,7 +136,10 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement) {
121
136
  return;
122
137
  }
123
138
 
124
- Idiomorph.morph(form, content);
139
+ const target = content.includes('<html') ? window.document.body : hsActionTag || form;
140
+
141
+ Idiomorph.morph(target, content);
142
+ opts.afterResponse && opts.afterResponse();
125
143
  });
126
144
  }
127
145
 
package/src/server.ts CHANGED
@@ -2,7 +2,7 @@ import { readdir } from 'node:fs/promises';
2
2
  import { basename, extname, join } from 'node:path';
3
3
  import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
4
4
  import { isbot } from 'isbot';
5
- import { buildClientJS, buildClientCSS } from './assets';
5
+ import { buildClientJS, buildClientCSS, assetHash } from './assets';
6
6
  import { Hono, type Context } from 'hono';
7
7
  import { serveStatic } from 'hono/bun';
8
8
  import { HTTPException } from 'hono/http-exception';
@@ -83,46 +83,14 @@ export function createRoute(handler?: THSRouteHandler): THSRoute {
83
83
  async (context: Context) => {
84
84
  const method = context.req.method.toUpperCase();
85
85
 
86
- try {
86
+ return returnHTMLResponse(context, () => {
87
87
  const handler = _handlers[method];
88
88
  if (!handler) {
89
89
  throw new HTTPException(405, { message: 'Method not allowed' });
90
90
  }
91
91
 
92
- const routeContent = await handler(context);
93
-
94
- // Return Response if returned from route handler
95
- if (routeContent instanceof Response) {
96
- return routeContent;
97
- }
98
-
99
- // @TODO: Move this to config or something...
100
- const userIsBot = isbot(context.req.header('User-Agent'));
101
- const streamOpt = context.req.query('__nostream');
102
- const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
103
-
104
- // Render HSHtml if returned from route handler
105
- if (isHSHtml(routeContent)) {
106
- // Stream only if enabled and there is async content to stream
107
- if (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
108
- return new StreamResponse(renderStream(routeContent as HSHtml)) as Response;
109
- } else {
110
- const output = await renderAsync(routeContent as HSHtml);
111
- return context.html(output);
112
- }
113
- }
114
-
115
- // Return custom Response if returned from route handler
116
- if (routeContent instanceof Response) {
117
- return routeContent;
118
- }
119
-
120
- // Return unknown content - not specifically handled above
121
- return context.text(String(routeContent));
122
- } catch (e) {
123
- !IS_PROD && console.error(e);
124
- return await showErrorReponse(context, e as Error);
125
- }
92
+ return handler(context);
93
+ });
126
94
  },
127
95
  ];
128
96
  },
@@ -224,6 +192,49 @@ export function createAPIRoute(handler?: THSAPIRouteHandler): THSAPIRoute {
224
192
  return api;
225
193
  }
226
194
 
195
+ /**
196
+ * Return HTML response from userland route handler
197
+ */
198
+ export async function returnHTMLResponse(
199
+ context: Context,
200
+ handlerFn: () => unknown,
201
+ responseOptions?: { status?: ContentfulStatusCode; headers?: Headers | Record<string, string> }
202
+ ): Promise<Response> {
203
+ try {
204
+ const routeContent = await handlerFn();
205
+
206
+ // Return Response if returned from route handler
207
+ if (routeContent instanceof Response) {
208
+ return routeContent;
209
+ }
210
+
211
+ // @TODO: Move this to config or something...
212
+ const userIsBot = isbot(context.req.header('User-Agent'));
213
+ const streamOpt = context.req.query('__nostream');
214
+ const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
215
+
216
+ // Render HSHtml if returned from route handler
217
+ if (isHSHtml(routeContent)) {
218
+ // Stream only if enabled and there is async content to stream
219
+ if (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
220
+ return new StreamResponse(
221
+ renderStream(routeContent as HSHtml),
222
+ responseOptions
223
+ ) as Response;
224
+ } else {
225
+ const output = await renderAsync(routeContent as HSHtml);
226
+ return context.html(output, responseOptions);
227
+ }
228
+ }
229
+
230
+ // Return unknown content as string - not specifically handled above
231
+ return context.html(String(routeContent), responseOptions);
232
+ } catch (e) {
233
+ !IS_PROD && console.error(e);
234
+ return await showErrorReponse(context, e as Error, responseOptions);
235
+ }
236
+ }
237
+
227
238
  /**
228
239
  * Get a Hyperspan runnable route from a module import
229
240
  * @throws Error if no runnable route found
@@ -271,7 +282,11 @@ export function isRunnableRoute(route: unknown): boolean {
271
282
  * Basic error handling
272
283
  * @TODO: Should check for and load user-customizeable template with special name (app/__error.ts ?)
273
284
  */
274
- async function showErrorReponse(context: Context, err: Error) {
285
+ async function showErrorReponse(
286
+ context: Context,
287
+ err: Error,
288
+ responseOptions?: { status?: ContentfulStatusCode; headers?: Headers | Record<string, string> }
289
+ ) {
275
290
  let status: ContentfulStatusCode = 500;
276
291
  const message = err.message || 'Internal Server Error';
277
292
 
@@ -282,6 +297,18 @@ async function showErrorReponse(context: Context, err: Error) {
282
297
 
283
298
  const stack = !IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : '';
284
299
 
300
+ // Partial request (no layout - usually from actions)
301
+ if (context.req.header('X-Request-Type') === 'partial') {
302
+ const output = render(html`
303
+ <section style="padding: 20px;">
304
+ <p style="margin-bottom: 10px;"><strong>Error</strong></p>
305
+ <strong>${message}</strong>
306
+ ${stack ? html`<pre>${stack}</pre>` : ''}
307
+ </section>
308
+ `);
309
+ return context.html(output, Object.assign({ status }, responseOptions));
310
+ }
311
+
285
312
  const output = render(html`
286
313
  <!DOCTYPE html>
287
314
  <html lang="en">
@@ -300,7 +327,7 @@ async function showErrorReponse(context: Context, err: Error) {
300
327
  </html>
301
328
  `);
302
329
 
303
- return context.html(output, { status });
330
+ return context.html(output, Object.assign({ status }, responseOptions));
304
331
  }
305
332
 
306
333
  export type THSServerConfig = {
@@ -317,6 +344,7 @@ export type THSRouteMap = {
317
344
  file: string;
318
345
  route: string;
319
346
  params: string[];
347
+ module?: any;
320
348
  };
321
349
 
322
350
  /**
@@ -363,12 +391,54 @@ export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[
363
391
 
364
392
  routes.push({
365
393
  file: join('./', routesDir, file),
366
- route: route || '/',
394
+ route: normalizePath(route || '/'),
367
395
  params,
368
396
  });
369
397
  }
370
398
 
371
- return routes;
399
+ // Import all routes at once
400
+ return await Promise.all(
401
+ routes.map(async (route) => {
402
+ route.module = (await import(join(CWD, route.file))).default;
403
+
404
+ return route;
405
+ })
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Build Hyperspan Actions
411
+ */
412
+ export async function buildActions(config: THSServerConfig): Promise<THSRouteMap[]> {
413
+ // Walk all pages and add them as routes
414
+ const routesDir = join(config.appDir, 'actions');
415
+ const files = await readdir(routesDir, { recursive: true });
416
+ const routes: THSRouteMap[] = [];
417
+
418
+ for (const file of files) {
419
+ // No directories
420
+ if (!file.includes('.') || basename(file).startsWith('.')) {
421
+ continue;
422
+ }
423
+
424
+ let route = assetHash('/' + file.replace(extname(file), ''));
425
+
426
+ routes.push({
427
+ file: join('./', routesDir, file),
428
+ route: `/__actions/${route}`,
429
+ params: [],
430
+ });
431
+ }
432
+
433
+ // Import all routes at once
434
+ return await Promise.all(
435
+ routes.map(async (route) => {
436
+ route.module = (await import(join(CWD, route.file))).default;
437
+ route.route = route.module._route;
438
+
439
+ return route;
440
+ })
441
+ );
372
442
  }
373
443
 
374
444
  /**
@@ -393,20 +463,24 @@ export async function createServer(config: THSServerConfig): Promise<Hono> {
393
463
  // [Customization] Before routes added...
394
464
  config.beforeRoutesAdded && config.beforeRoutesAdded(app);
395
465
 
466
+ const [routes, actions] = await Promise.all([buildRoutes(config), buildActions(config)]);
467
+
396
468
  // Scan routes folder and add all file routes to the router
397
- const fileRoutes = await buildRoutes(config);
469
+ const fileRoutes = routes.concat(actions);
398
470
  const routeMap = [];
399
471
 
400
472
  for (let i = 0; i < fileRoutes.length; i++) {
401
473
  let route = fileRoutes[i];
402
- const fullRouteFile = join(CWD, route.file);
403
- const routePattern = normalizePath(route.route);
404
474
 
405
- routeMap.push({ route: routePattern, file: route.file });
475
+ routeMap.push({ route: route.route, file: route.file });
476
+
477
+ // Ensure route module was imported and exists (it should...)
478
+ if (!route.module) {
479
+ throw new Error(`Route module not loaded! File: ${route.file}`);
480
+ }
406
481
 
407
- // Import route
408
- const routeHandlers = createRouteFromModule(await import(fullRouteFile));
409
- app.all(routePattern, ...routeHandlers);
482
+ const routeHandlers = createRouteFromModule(route.module);
483
+ app.all(route.route, ...routeHandlers);
410
484
  }
411
485
 
412
486
  // Help route if no routes found