@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 +1 -1
- package/dist/assets.js +2 -0
- package/dist/chunk-atw8cdg1.js +19 -0
- package/dist/middleware.js +178 -0
- package/dist/server.js +76 -34
- package/package.json +12 -6
- package/src/actions.test.ts +9 -7
- package/src/actions.ts +92 -31
- package/src/clientjs/hyperspan-client.ts +37 -19
- package/src/clientjs/{idiomorph.esm.js → idiomorph.ts} +34 -34
- package/src/middleware.ts +19 -0
- package/src/server.ts +131 -46
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
|
@@ -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
|
-
|
|
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
|
-
|
|
1865
|
-
|
|
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
|
|
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
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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.
|
|
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
|
-
"
|
|
71
|
+
"timestring": "^7.0.0",
|
|
72
|
+
"zod": "^3.25.67"
|
|
67
73
|
}
|
|
68
74
|
}
|
package/src/actions.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import z from 'zod
|
|
2
|
-
import {
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
export function
|
|
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 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(
|
|
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 =
|
|
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 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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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 =
|
|
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} */
|
|
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} */
|
|
435
|
-
const newElt = /** @type {Element} */
|
|
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} */
|
|
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
|
-
|
|
508
|
-
|
|
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} */
|
|
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} */
|
|
617
|
-
const newElt = /** @type {Element} */
|
|
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
|
-
|
|
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} */
|
|
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} */
|
|
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} */
|
|
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} */
|
|
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(
|
|
1182
|
-
|
|
1183
|
-
if (node
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1190
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
397
|
-
|
|
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
|