@hyperspan/framework 0.3.2 → 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/build.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { build } from 'bun';
2
2
 
3
- const entrypoints = ['./src/server.ts', './src/assets.ts'];
3
+ const entrypoints = ['./src/server.ts', './src/assets.ts', './src/middleware.ts'];
4
4
  const external = ['@hyperspan/html'];
5
5
  const outdir = './dist';
6
6
  const target = 'node';
package/dist/assets.js CHANGED
@@ -1,3 +1,5 @@
1
+ import"./chunk-atw8cdg1.js";
2
+
1
3
  // src/assets.ts
2
4
  import { html } from "@hyperspan/html";
3
5
  import { createHash } from "node:crypto";
@@ -0,0 +1,19 @@
1
+ var __create = Object.create;
2
+ var __getProtoOf = Object.getPrototypeOf;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __toESM = (mod, isNodeMode, target) => {
7
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
8
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
9
+ for (let key of __getOwnPropNames(mod))
10
+ if (!__hasOwnProp.call(to, key))
11
+ __defProp(to, key, {
12
+ get: () => mod[key],
13
+ enumerable: true
14
+ });
15
+ return to;
16
+ };
17
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
18
+
19
+ export { __toESM, __commonJS };
@@ -0,0 +1,178 @@
1
+ import {
2
+ __commonJS,
3
+ __toESM
4
+ } from "./chunk-atw8cdg1.js";
5
+
6
+ // ../../node_modules/timestring/index.js
7
+ var require_timestring = __commonJS((exports, module) => {
8
+ module.exports = parseTimestring;
9
+ var DEFAULT_OPTS = {
10
+ hoursPerDay: 24,
11
+ daysPerWeek: 7,
12
+ weeksPerMonth: 4,
13
+ monthsPerYear: 12,
14
+ daysPerYear: 365.25
15
+ };
16
+ var UNIT_MAP = {
17
+ ms: ["ms", "milli", "millisecond", "milliseconds"],
18
+ s: ["s", "sec", "secs", "second", "seconds"],
19
+ m: ["m", "min", "mins", "minute", "minutes"],
20
+ h: ["h", "hr", "hrs", "hour", "hours"],
21
+ d: ["d", "day", "days"],
22
+ w: ["w", "week", "weeks"],
23
+ mth: ["mon", "mth", "mths", "month", "months"],
24
+ y: ["y", "yr", "yrs", "year", "years"]
25
+ };
26
+ function parseTimestring(value, returnUnit, opts) {
27
+ opts = Object.assign({}, DEFAULT_OPTS, opts || {});
28
+ if (typeof value === "number" || value.match(/^[-+]?[0-9.]+$/g)) {
29
+ value = parseInt(value) + "ms";
30
+ }
31
+ let totalSeconds = 0;
32
+ const unitValues = getUnitValues(opts);
33
+ const groups = value.toLowerCase().replace(/[^.\w+-]+/g, "").match(/[-+]?[0-9.]+[a-z]+/g);
34
+ if (groups === null) {
35
+ throw new Error(`The value [${value}] could not be parsed by timestring`);
36
+ }
37
+ groups.forEach((group) => {
38
+ const value2 = group.match(/[0-9.]+/g)[0];
39
+ const unit = group.match(/[a-z]+/g)[0];
40
+ totalSeconds += getSeconds(value2, unit, unitValues);
41
+ });
42
+ if (returnUnit) {
43
+ return convert(totalSeconds, returnUnit, unitValues);
44
+ }
45
+ return totalSeconds;
46
+ }
47
+ function getUnitValues(opts) {
48
+ const unitValues = {
49
+ ms: 0.001,
50
+ s: 1,
51
+ m: 60,
52
+ h: 3600
53
+ };
54
+ unitValues.d = opts.hoursPerDay * unitValues.h;
55
+ unitValues.w = opts.daysPerWeek * unitValues.d;
56
+ unitValues.mth = opts.daysPerYear / opts.monthsPerYear * unitValues.d;
57
+ unitValues.y = opts.daysPerYear * unitValues.d;
58
+ return unitValues;
59
+ }
60
+ function getUnitKey(unit) {
61
+ for (const key of Object.keys(UNIT_MAP)) {
62
+ if (UNIT_MAP[key].indexOf(unit) > -1) {
63
+ return key;
64
+ }
65
+ }
66
+ throw new Error(`The unit [${unit}] is not supported by timestring`);
67
+ }
68
+ function getSeconds(value, unit, unitValues) {
69
+ return value * unitValues[getUnitKey(unit)];
70
+ }
71
+ function convert(value, unit, unitValues) {
72
+ return value / unitValues[getUnitKey(unit)];
73
+ }
74
+ });
75
+
76
+ // ../../node_modules/hono/dist/middleware/etag/digest.js
77
+ var mergeBuffers = (buffer1, buffer2) => {
78
+ if (!buffer1) {
79
+ return buffer2;
80
+ }
81
+ const merged = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
82
+ merged.set(new Uint8Array(buffer1), 0);
83
+ merged.set(buffer2, buffer1.byteLength);
84
+ return merged;
85
+ };
86
+ var generateDigest = async (stream, generator) => {
87
+ if (!stream) {
88
+ return null;
89
+ }
90
+ let result = undefined;
91
+ const reader = stream.getReader();
92
+ for (;; ) {
93
+ const { value, done } = await reader.read();
94
+ if (done) {
95
+ break;
96
+ }
97
+ result = await generator(mergeBuffers(result, value));
98
+ }
99
+ if (!result) {
100
+ return null;
101
+ }
102
+ return Array.prototype.map.call(new Uint8Array(result), (x) => x.toString(16).padStart(2, "0")).join("");
103
+ };
104
+
105
+ // ../../node_modules/hono/dist/middleware/etag/index.js
106
+ var RETAINED_304_HEADERS = [
107
+ "cache-control",
108
+ "content-location",
109
+ "date",
110
+ "etag",
111
+ "expires",
112
+ "vary"
113
+ ];
114
+ function etagMatches(etag2, ifNoneMatch) {
115
+ return ifNoneMatch != null && ifNoneMatch.split(/,\s*/).indexOf(etag2) > -1;
116
+ }
117
+ function initializeGenerator(generator) {
118
+ if (!generator) {
119
+ if (crypto && crypto.subtle) {
120
+ generator = (body) => crypto.subtle.digest({
121
+ name: "SHA-1"
122
+ }, body);
123
+ }
124
+ }
125
+ return generator;
126
+ }
127
+ var etag = (options) => {
128
+ const retainedHeaders = options?.retainedHeaders ?? RETAINED_304_HEADERS;
129
+ const weak = options?.weak ?? false;
130
+ const generator = initializeGenerator(options?.generateDigest);
131
+ return async function etag2(c, next) {
132
+ const ifNoneMatch = c.req.header("If-None-Match") ?? null;
133
+ await next();
134
+ const res = c.res;
135
+ let etag3 = res.headers.get("ETag");
136
+ if (!etag3) {
137
+ if (!generator) {
138
+ return;
139
+ }
140
+ const hash = await generateDigest(res.clone().body, generator);
141
+ if (hash === null) {
142
+ return;
143
+ }
144
+ etag3 = weak ? `W/"${hash}"` : `"${hash}"`;
145
+ }
146
+ if (etagMatches(etag3, ifNoneMatch)) {
147
+ c.res = new Response(null, {
148
+ status: 304,
149
+ statusText: "Not Modified",
150
+ headers: {
151
+ ETag: etag3
152
+ }
153
+ });
154
+ c.res.headers.forEach((_, key) => {
155
+ if (retainedHeaders.indexOf(key.toLowerCase()) === -1) {
156
+ c.res.headers.delete(key);
157
+ }
158
+ });
159
+ } else {
160
+ c.res.headers.set("ETag", etag3);
161
+ }
162
+ };
163
+ };
164
+
165
+ // src/middleware.ts
166
+ var import_timestring = __toESM(require_timestring(), 1);
167
+ function cacheTime(timeStrOrSeconds) {
168
+ return (c, next) => etag()(c, () => {
169
+ if (c.req.method.toUpperCase() === "GET") {
170
+ const timeInSeconds = typeof timeStrOrSeconds === "number" ? timeStrOrSeconds : import_timestring.default(timeStrOrSeconds);
171
+ c.header("Cache-Control", `public, max-age=${timeInSeconds}`);
172
+ }
173
+ return next();
174
+ });
175
+ }
176
+ export {
177
+ cacheTime
178
+ };
package/dist/server.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import {
2
+ assetHash,
2
3
  buildClientCSS,
3
4
  buildClientJS
4
5
  } from "./assets.js";
6
+ import"./chunk-atw8cdg1.js";
5
7
 
6
8
  // src/server.ts
7
9
  import { readdir } from "node:fs/promises";
@@ -1856,34 +1858,13 @@ function createRoute(handler) {
1856
1858
  ..._middleware,
1857
1859
  async (context) => {
1858
1860
  const method = context.req.method.toUpperCase();
1859
- try {
1861
+ return returnHTMLResponse(context, () => {
1860
1862
  const handler2 = _handlers[method];
1861
1863
  if (!handler2) {
1862
1864
  throw new HTTPException(405, { message: "Method not allowed" });
1863
1865
  }
1864
- const routeContent = await handler2(context);
1865
- if (routeContent instanceof Response) {
1866
- return routeContent;
1867
- }
1868
- const userIsBot = isbot(context.req.header("User-Agent"));
1869
- const streamOpt = context.req.query("__nostream");
1870
- const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
1871
- if (isHSHtml(routeContent)) {
1872
- if (streamingEnabled) {
1873
- return new StreamResponse(renderStream(routeContent));
1874
- } else {
1875
- const output = await renderAsync(routeContent);
1876
- return context.html(output);
1877
- }
1878
- }
1879
- if (routeContent instanceof Response) {
1880
- return routeContent;
1881
- }
1882
- return context.text(String(routeContent));
1883
- } catch (e) {
1884
- !IS_PROD && console.error(e);
1885
- return await showErrorReponse(context, e);
1886
- }
1866
+ return handler2(context);
1867
+ });
1887
1868
  }
1888
1869
  ];
1889
1870
  }
@@ -1962,6 +1943,29 @@ function createAPIRoute(handler) {
1962
1943
  };
1963
1944
  return api;
1964
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
+ }
1965
1969
  function getRunnableRoute(route) {
1966
1970
  if (isRunnableRoute(route)) {
1967
1971
  return route;
@@ -1983,7 +1987,7 @@ function isRunnableRoute(route) {
1983
1987
  const runnableKind = ["hsRoute", "hsAPIRoute", "hsAction"].includes(obj?._kind);
1984
1988
  return runnableKind && "_getRouteHandlers" in obj;
1985
1989
  }
1986
- async function showErrorReponse(context, err) {
1990
+ async function showErrorReponse(context, err, responseOptions) {
1987
1991
  let status = 500;
1988
1992
  const message = err.message || "Internal Server Error";
1989
1993
  if (err instanceof HTTPException) {
@@ -1992,6 +1996,16 @@ async function showErrorReponse(context, err) {
1992
1996
  const stack = !IS_PROD && err.stack ? err.stack.split(`
1993
1997
  `).slice(1).join(`
1994
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
+ }
1995
2009
  const output = render(html`
1996
2010
  <!DOCTYPE html>
1997
2011
  <html lang="en">
@@ -2009,7 +2023,7 @@ async function showErrorReponse(context, err) {
2009
2023
  </body>
2010
2024
  </html>
2011
2025
  `);
2012
- return context.html(output, { status });
2026
+ return context.html(output, Object.assign({ status }, responseOptions));
2013
2027
  }
2014
2028
  var ROUTE_SEGMENT = /(\[[a-zA-Z_\.]+\])/g;
2015
2029
  async function buildRoutes(config) {
@@ -2041,11 +2055,35 @@ async function buildRoutes(config) {
2041
2055
  }
2042
2056
  routes.push({
2043
2057
  file: join("./", routesDir, file),
2044
- route: route || "/",
2058
+ route: normalizePath(route || "/"),
2045
2059
  params
2046
2060
  });
2047
2061
  }
2048
- 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
+ }));
2049
2087
  }
2050
2088
  function createRouteFromModule(RouteModule) {
2051
2089
  const route = getRunnableRoute(RouteModule);
@@ -2055,15 +2093,17 @@ async function createServer(config) {
2055
2093
  await Promise.all([buildClientJS(), buildClientCSS()]);
2056
2094
  const app = new Hono2;
2057
2095
  config.beforeRoutesAdded && config.beforeRoutesAdded(app);
2058
- const fileRoutes = await buildRoutes(config);
2096
+ const [routes, actions] = await Promise.all([buildRoutes(config), buildActions(config)]);
2097
+ const fileRoutes = routes.concat(actions);
2059
2098
  const routeMap = [];
2060
2099
  for (let i = 0;i < fileRoutes.length; i++) {
2061
2100
  let route = fileRoutes[i];
2062
- const fullRouteFile = join(CWD, route.file);
2063
- const routePattern = normalizePath(route.route);
2064
- routeMap.push({ route: routePattern, file: route.file });
2065
- const routeHandlers = createRouteFromModule(await import(fullRouteFile));
2066
- 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);
2067
2107
  }
2068
2108
  if (routeMap.length === 0) {
2069
2109
  app.get("/", (context) => {
@@ -2122,6 +2162,7 @@ function normalizePath(urlPath) {
2122
2162
  return (urlPath.endsWith("/") ? urlPath.substring(0, urlPath.length - 1) : urlPath).toLowerCase() || "/";
2123
2163
  }
2124
2164
  export {
2165
+ returnHTMLResponse,
2125
2166
  normalizePath,
2126
2167
  isRunnableRoute,
2127
2168
  getRunnableRoute,
@@ -2132,6 +2173,7 @@ export {
2132
2173
  createConfig,
2133
2174
  createAPIRoute,
2134
2175
  buildRoutes,
2176
+ buildActions,
2135
2177
  StreamResponse,
2136
2178
  IS_PROD
2137
2179
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "dist/server.ts",
6
6
  "types": "src/server.ts",
@@ -17,13 +17,17 @@
17
17
  "types": "./src/server.ts",
18
18
  "default": "./dist/server.js"
19
19
  },
20
- "./actions": {
21
- "types": "./src/actions.ts",
22
- "default": "./src/actions.ts"
23
- },
24
20
  "./assets": {
25
21
  "types": "./src/assets.ts",
26
22
  "default": "./dist/assets.js"
23
+ },
24
+ "./middleware": {
25
+ "types": "./src/middleware.ts",
26
+ "default": "./dist/middleware.js"
27
+ },
28
+ "./unstable/actions": {
29
+ "types": "./src/actions.ts",
30
+ "default": "./src/actions.ts"
27
31
  }
28
32
  },
29
33
  "author": "Vance Lucas <vance@vancelucas.com>",
@@ -56,6 +60,7 @@
56
60
  "@types/bun": "^1.2.14",
57
61
  "@types/node": "^22.15.20",
58
62
  "@types/react": "^19.1.5",
63
+ "@types/timestring": "^7.0.0",
59
64
  "prettier": "^3.5.3",
60
65
  "typescript": "^5.8.3"
61
66
  },
@@ -63,6 +68,7 @@
63
68
  "@hyperspan/html": "^0.1.7",
64
69
  "hono": "^4.7.10",
65
70
  "isbot": "^5.1.28",
66
- "zod": "^3.25.42"
71
+ "timestring": "^7.0.0",
72
+ "zod": "^3.25.67"
67
73
  }
68
74
  }
@@ -1,5 +1,5 @@
1
- import z from 'zod/v4';
2
- import { createAction } from './actions';
1
+ import { z } from 'zod';
2
+ import { unstable__createAction } from './actions';
3
3
  import { describe, it, expect } from 'bun:test';
4
4
  import { html, render, type HSHtml } from '@hyperspan/html';
5
5
  import type { Context } from 'hono';
@@ -22,7 +22,7 @@ describe('createAction', () => {
22
22
  const schema = z.object({
23
23
  name: z.string(),
24
24
  });
25
- const action = createAction(schema, formWithNameOnly);
25
+ const action = unstable__createAction(schema, formWithNameOnly);
26
26
 
27
27
  const formResponse = render(action.render({ data: { name: 'John' } }) as HSHtml);
28
28
  expect(formResponse).toContain('value="John"');
@@ -34,7 +34,7 @@ describe('createAction', () => {
34
34
  const schema = z.object({
35
35
  name: z.string().nonempty(),
36
36
  });
37
- const action = createAction(schema, formWithNameOnly)
37
+ const action = unstable__createAction(schema, formWithNameOnly)
38
38
  .post((c, { data }) => {
39
39
  return html`<div>Thanks for submitting the form, ${data?.name}!</div>`;
40
40
  })
@@ -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!');
@@ -65,7 +66,7 @@ describe('createAction', () => {
65
66
  const schema = z.object({
66
67
  name: z.string().nonempty(),
67
68
  });
68
- const action = createAction(schema)
69
+ const action = unstable__createAction(schema)
69
70
  .form(formWithNameOnly)
70
71
  .post((c, { data }) => {
71
72
  return html`<div>Thanks for submitting the form, ${data?.name}!</div>`;
@@ -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
- export function createAction<T extends z.ZodTypeAny>(
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 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 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') {
@@ -87,9 +144,9 @@ export function createAction<T extends z.ZodTypeAny>(
87
144
  }
88
145
 
89
146
  const formData = await c.req.formData();
90
- const jsonData = formDataToJSON(formData);
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 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
 
@@ -128,7 +189,7 @@ export type THSHandlerResponse = (context: Context) => THSResponseTypes | Promis
128
189
  *
129
190
  * @link https://stackoverflow.com/a/75406413
130
191
  */
131
- export function formDataToJSON(formData: FormData): Record<string, string | string[]> {
192
+ export function unstable__formDataToJSON(formData: FormData): Record<string, string | string[]> {
132
193
  let object = {};
133
194
 
134
195
  /**
@@ -1,5 +1,5 @@
1
1
  import { html } from '@hyperspan/html';
2
- import { Idiomorph } from './idiomorph.esm';
2
+ import { Idiomorph } from './idiomorph';
3
3
 
4
4
  /**
5
5
  * Used for streaming content from the server to the client.
@@ -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
 
@@ -1,3 +1,4 @@
1
+ // @ts-nocheck
1
2
  /**
2
3
  * @typedef {object} ConfigHead
3
4
  *
@@ -206,9 +207,8 @@ var Idiomorph = (function () {
206
207
  */
207
208
  function saveAndRestoreFocus(ctx, fn) {
208
209
  if (!ctx.config.restoreFocus) return fn();
209
- let activeElement = /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
210
- document.activeElement
211
- );
210
+ let activeElement =
211
+ /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ document.activeElement;
212
212
 
213
213
  // don't bother if the active element is not an input or textarea
214
214
  if (
@@ -324,7 +324,7 @@ var Idiomorph = (function () {
324
324
  if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
325
325
  if (ctx.idMap.has(newChild)) {
326
326
  // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
327
- const newEmptyChild = document.createElement(/** @type {Element} */ (newChild).tagName);
327
+ const newEmptyChild = document.createElement(/** @type {Element} */ newChild.tagName);
328
328
  oldParent.insertBefore(newEmptyChild, insertionPoint);
329
329
  morphNode(newEmptyChild, newChild, ctx);
330
330
  ctx.callbacks.afterNodeAdded(newEmptyChild);
@@ -431,8 +431,8 @@ var Idiomorph = (function () {
431
431
  */
432
432
  function isSoftMatch(oldNode, newNode) {
433
433
  // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
434
- const oldElt = /** @type {Element} */ (oldNode);
435
- const newElt = /** @type {Element} */ (newNode);
434
+ const oldElt = /** @type {Element} */ oldNode;
435
+ const newElt = /** @type {Element} */ newNode;
436
436
 
437
437
  return (
438
438
  oldElt.nodeType === newElt.nodeType &&
@@ -483,7 +483,7 @@ var Idiomorph = (function () {
483
483
  let cursor = startInclusive;
484
484
  // remove nodes until the endExclusive node
485
485
  while (cursor && cursor !== endExclusive) {
486
- let tempNode = /** @type {Node} */ (cursor);
486
+ let tempNode = /** @type {Node} */ cursor;
487
487
  cursor = cursor.nextSibling;
488
488
  removeNode(ctx, tempNode);
489
489
  }
@@ -503,11 +503,9 @@ var Idiomorph = (function () {
503
503
  function moveBeforeById(parentNode, id, after, ctx) {
504
504
  const target =
505
505
  /** @type {Element} - will always be found */
506
- (
507
- (ctx.target.id === id && ctx.target) ||
508
- ctx.target.querySelector(`[id="${id}"]`) ||
509
- ctx.pantry.querySelector(`[id="${id}"]`)
510
- );
506
+ (ctx.target.id === id && ctx.target) ||
507
+ ctx.target.querySelector(`[id="${id}"]`) ||
508
+ ctx.pantry.querySelector(`[id="${id}"]`);
511
509
  removeElementFromAncestorsIdMaps(target, ctx);
512
510
  moveBefore(parentNode, target, after);
513
511
  return target;
@@ -587,7 +585,7 @@ var Idiomorph = (function () {
587
585
  // ignore the head element
588
586
  } else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== 'morph') {
589
587
  // ok to cast: if newContent wasn't also a <head>, it would've got caught in the `!isSoftMatch` branch above
590
- handleHeadElement(oldNode, /** @type {HTMLHeadElement} */ (newContent), ctx);
588
+ handleHeadElement(oldNode, /** @type {HTMLHeadElement} */ newContent, ctx);
591
589
  } else {
592
590
  morphAttributes(oldNode, newContent, ctx);
593
591
  if (!ignoreValueOfActiveElement(oldNode, ctx)) {
@@ -613,8 +611,8 @@ var Idiomorph = (function () {
613
611
  // if is an element type, sync the attributes from the
614
612
  // new node into the new node
615
613
  if (type === 1 /* element type */) {
616
- const oldElt = /** @type {Element} */ (oldNode);
617
- const newElt = /** @type {Element} */ (newNode);
614
+ const oldElt = /** @type {Element} */ oldNode;
615
+ const newElt = /** @type {Element} */ newNode;
618
616
 
619
617
  const oldAttributes = oldElt.attributes;
620
618
  const newAttributes = newElt.attributes;
@@ -868,9 +866,9 @@ var Idiomorph = (function () {
868
866
  let promises = [];
869
867
  for (const newNode of nodesToAppend) {
870
868
  // TODO: This could theoretically be null, based on type
871
- let newElt = /** @type {ChildNode} */ (
872
- document.createRange().createContextualFragment(newNode.outerHTML).firstChild
873
- );
869
+ let newElt = /** @type {ChildNode} */ document
870
+ .createRange()
871
+ .createContextualFragment(newNode.outerHTML).firstChild;
874
872
  if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
875
873
  if (('href' in newElt && newElt.href) || ('src' in newElt && newElt.src)) {
876
874
  /** @type {(result?: any) => void} */ let resolve;
@@ -1116,16 +1114,16 @@ var Idiomorph = (function () {
1116
1114
  return document.createElement('div'); // dummy parent element
1117
1115
  } else if (typeof newContent === 'string') {
1118
1116
  return normalizeParent(parseContent(newContent));
1119
- } else if (generatedByIdiomorph.has(/** @type {Element} */ (newContent))) {
1117
+ } else if (generatedByIdiomorph.has(/** @type {Element} */ newContent)) {
1120
1118
  // the template tag created by idiomorph parsing can serve as a dummy parent
1121
- return /** @type {Element} */ (newContent);
1119
+ return /** @type {Element} */ newContent;
1122
1120
  } else if (newContent instanceof Node) {
1123
1121
  if (newContent.parentNode) {
1124
1122
  // we can't use the parent directly because newContent may have siblings
1125
1123
  // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
1126
1124
  // so instead we create a fake parent node that only sees a slice of its children.
1127
1125
  /** @type {Element} */
1128
- return /** @type {any} */ (new SlicedParentNode(newContent));
1126
+ return /** @type {any} */ new SlicedParentNode(newContent);
1129
1127
  } else {
1130
1128
  // a single node is added as a child to a dummy parent
1131
1129
  const dummyParent = document.createElement('div');
@@ -1154,7 +1152,7 @@ var Idiomorph = (function () {
1154
1152
  /** @param {Node} node */
1155
1153
  constructor(node) {
1156
1154
  this.originalNode = node;
1157
- this.realParentNode = /** @type {Element} */ (node.parentNode);
1155
+ this.realParentNode = /** @type {Element} */ node.parentNode;
1158
1156
  this.previousSibling = node.previousSibling;
1159
1157
  this.nextSibling = node.nextSibling;
1160
1158
  }
@@ -1178,16 +1176,19 @@ var Idiomorph = (function () {
1178
1176
  * @returns {Element[]}
1179
1177
  */
1180
1178
  querySelectorAll(selector) {
1181
- return this.childNodes.reduce((results, node) => {
1182
- if (node instanceof Element) {
1183
- if (node.matches(selector)) results.push(node);
1184
- const nodeList = node.querySelectorAll(selector);
1185
- for (let i = 0; i < nodeList.length; i++) {
1186
- results.push(nodeList[i]);
1179
+ return this.childNodes.reduce(
1180
+ (results, node) => {
1181
+ if (node instanceof Element) {
1182
+ if (node.matches(selector)) results.push(node);
1183
+ const nodeList = node.querySelectorAll(selector);
1184
+ for (let i = 0; i < nodeList.length; i++) {
1185
+ results.push(nodeList[i]);
1186
+ }
1187
1187
  }
1188
- }
1189
- return results;
1190
- }, /** @type {Element[]} */ ([]));
1188
+ return results;
1189
+ },
1190
+ /** @type {Element[]} */ []
1191
+ );
1191
1192
  }
1192
1193
 
1193
1194
  /**
@@ -1255,9 +1256,8 @@ var Idiomorph = (function () {
1255
1256
  '<body><template>' + newContent + '</template></body>',
1256
1257
  'text/html'
1257
1258
  );
1258
- let content = /** @type {HTMLTemplateElement} */ (
1259
- responseDoc.body.querySelector('template')
1260
- ).content;
1259
+ let content =
1260
+ /** @type {HTMLTemplateElement} */ responseDoc.body.querySelector('template').content;
1261
1261
  generatedByIdiomorph.add(content);
1262
1262
  return content;
1263
1263
  }
@@ -0,0 +1,19 @@
1
+ import { Context, Next } from 'hono';
2
+ import { etag } from 'hono/etag';
3
+ import timestring from 'timestring';
4
+
5
+ /**
6
+ * Cache the response for a given time length ('30s', '1d', '1w', '1m', etc) or given number of seconds
7
+ */
8
+ export function cacheTime(timeStrOrSeconds: string | number) {
9
+ return (c: Context, next: Next) =>
10
+ etag()(c, () => {
11
+ // Only cache GET requests
12
+ if (c.req.method.toUpperCase() === 'GET') {
13
+ const timeInSeconds =
14
+ typeof timeStrOrSeconds === 'number' ? timeStrOrSeconds : timestring(timeStrOrSeconds);
15
+ c.header('Cache-Control', `public, max-age=${timeInSeconds}`);
16
+ }
17
+ return next();
18
+ });
19
+ }
package/src/server.ts CHANGED
@@ -2,10 +2,11 @@ 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';
9
+
9
10
  import type { HandlerResponse, MiddlewareHandler } from 'hono/types';
10
11
  import type { ContentfulStatusCode } from 'hono/utils/http-status';
11
12
 
@@ -55,14 +56,23 @@ export function createRoute(handler?: THSRouteHandler): THSRoute {
55
56
 
56
57
  const api: THSRoute = {
57
58
  _kind: 'hsRoute',
59
+ /**
60
+ * Add a GET route handler (primary page display)
61
+ */
58
62
  get(handler: THSRouteHandler) {
59
63
  _handlers['GET'] = handler;
60
64
  return api;
61
65
  },
66
+ /**
67
+ * Add a POST route handler (typically to process form data)
68
+ */
62
69
  post(handler: THSRouteHandler) {
63
70
  _handlers['POST'] = handler;
64
71
  return api;
65
72
  },
73
+ /**
74
+ * Add middleware specific to this route
75
+ */
66
76
  middleware(middleware: Array<MiddlewareHandler>) {
67
77
  _middleware = middleware;
68
78
  return api;
@@ -73,45 +83,14 @@ export function createRoute(handler?: THSRouteHandler): THSRoute {
73
83
  async (context: Context) => {
74
84
  const method = context.req.method.toUpperCase();
75
85
 
76
- try {
86
+ return returnHTMLResponse(context, () => {
77
87
  const handler = _handlers[method];
78
88
  if (!handler) {
79
89
  throw new HTTPException(405, { message: 'Method not allowed' });
80
90
  }
81
91
 
82
- const routeContent = await handler(context);
83
-
84
- // Return Response if returned from route handler
85
- if (routeContent instanceof Response) {
86
- return routeContent;
87
- }
88
-
89
- // @TODO: Move this to config or something...
90
- const userIsBot = isbot(context.req.header('User-Agent'));
91
- const streamOpt = context.req.query('__nostream');
92
- const streamingEnabled = !userIsBot && (streamOpt !== undefined ? streamOpt : true);
93
-
94
- // Render HSHtml if returned from route handler
95
- if (isHSHtml(routeContent)) {
96
- if (streamingEnabled) {
97
- return new StreamResponse(renderStream(routeContent as HSHtml)) as Response;
98
- } else {
99
- const output = await renderAsync(routeContent as HSHtml);
100
- return context.html(output);
101
- }
102
- }
103
-
104
- // Return custom Response if returned from route handler
105
- if (routeContent instanceof Response) {
106
- return routeContent;
107
- }
108
-
109
- // Return unknown content - not specifically handled above
110
- return context.text(String(routeContent));
111
- } catch (e) {
112
- !IS_PROD && console.error(e);
113
- return await showErrorReponse(context, e as Error);
114
- }
92
+ return handler(context);
93
+ });
115
94
  },
116
95
  ];
117
96
  },
@@ -213,6 +192,49 @@ export function createAPIRoute(handler?: THSAPIRouteHandler): THSAPIRoute {
213
192
  return api;
214
193
  }
215
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
+
216
238
  /**
217
239
  * Get a Hyperspan runnable route from a module import
218
240
  * @throws Error if no runnable route found
@@ -260,7 +282,11 @@ export function isRunnableRoute(route: unknown): boolean {
260
282
  * Basic error handling
261
283
  * @TODO: Should check for and load user-customizeable template with special name (app/__error.ts ?)
262
284
  */
263
- 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
+ ) {
264
290
  let status: ContentfulStatusCode = 500;
265
291
  const message = err.message || 'Internal Server Error';
266
292
 
@@ -271,6 +297,18 @@ async function showErrorReponse(context: Context, err: Error) {
271
297
 
272
298
  const stack = !IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : '';
273
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
+
274
312
  const output = render(html`
275
313
  <!DOCTYPE html>
276
314
  <html lang="en">
@@ -289,7 +327,7 @@ async function showErrorReponse(context: Context, err: Error) {
289
327
  </html>
290
328
  `);
291
329
 
292
- return context.html(output, { status });
330
+ return context.html(output, Object.assign({ status }, responseOptions));
293
331
  }
294
332
 
295
333
  export type THSServerConfig = {
@@ -306,6 +344,7 @@ export type THSRouteMap = {
306
344
  file: string;
307
345
  route: string;
308
346
  params: string[];
347
+ module?: any;
309
348
  };
310
349
 
311
350
  /**
@@ -352,12 +391,54 @@ export async function buildRoutes(config: THSServerConfig): Promise<THSRouteMap[
352
391
 
353
392
  routes.push({
354
393
  file: join('./', routesDir, file),
355
- route: route || '/',
394
+ route: normalizePath(route || '/'),
356
395
  params,
357
396
  });
358
397
  }
359
398
 
360
- 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
+ );
361
442
  }
362
443
 
363
444
  /**
@@ -382,20 +463,24 @@ export async function createServer(config: THSServerConfig): Promise<Hono> {
382
463
  // [Customization] Before routes added...
383
464
  config.beforeRoutesAdded && config.beforeRoutesAdded(app);
384
465
 
466
+ const [routes, actions] = await Promise.all([buildRoutes(config), buildActions(config)]);
467
+
385
468
  // Scan routes folder and add all file routes to the router
386
- const fileRoutes = await buildRoutes(config);
469
+ const fileRoutes = routes.concat(actions);
387
470
  const routeMap = [];
388
471
 
389
472
  for (let i = 0; i < fileRoutes.length; i++) {
390
473
  let route = fileRoutes[i];
391
- const fullRouteFile = join(CWD, route.file);
392
- const routePattern = normalizePath(route.route);
393
474
 
394
- 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
+ }
395
481
 
396
- // Import route
397
- const routeHandlers = createRouteFromModule(await import(fullRouteFile));
398
- app.all(routePattern, ...routeHandlers);
482
+ const routeHandlers = createRouteFromModule(route.module);
483
+ app.all(route.route, ...routeHandlers);
399
484
  }
400
485
 
401
486
  // Help route if no routes found