@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 +75 -34
- package/package.json +1 -1
- package/src/actions.test.ts +4 -2
- package/src/actions.ts +89 -28
- package/src/clientjs/hyperspan-client.ts +36 -18
- package/src/server.ts +121 -47
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
|
-
|
|
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
|
-
|
|
1866
|
-
|
|
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
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
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
package/src/actions.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
14
|
+
* HOW THIS WORKS:
|
|
13
15
|
* ---
|
|
14
|
-
* 1. Renders
|
|
15
|
-
* 2.
|
|
16
|
-
* 3. Submits form with JavaScript fetch()
|
|
17
|
-
* 4.
|
|
18
|
-
* 5.
|
|
19
|
-
* 6. Handles any Exception thrown on server as error displayed
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
) =>
|
|
41
|
+
) => TActionResponse
|
|
30
42
|
): HSAction<T>;
|
|
31
|
-
render(props?: { data?: z.infer<T>; error?: z.ZodError | Error }):
|
|
32
|
-
run(
|
|
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]
|
|
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]
|
|
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(
|
|
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>) :
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
408
|
-
|
|
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
|