@hyperspan/framework 0.3.5 → 0.4.1
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/assets.js +37 -4
- package/dist/server.js +105 -5
- package/package.json +1 -1
- package/src/assets.ts +56 -3
- package/src/clientjs/hyperspan-client.ts +56 -24
- package/src/plugins.ts +89 -0
- package/src/server.ts +54 -5
package/dist/assets.js
CHANGED
|
@@ -7,18 +7,49 @@ import { readdir } from "node:fs/promises";
|
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
var IS_PROD = false;
|
|
9
9
|
var PWD = import.meta.dir;
|
|
10
|
+
var CLIENTJS_PUBLIC_PATH = "/_hs/js";
|
|
11
|
+
var ISLAND_PUBLIC_PATH = "/_hs/js/islands";
|
|
10
12
|
var clientImportMap = new Map;
|
|
11
13
|
var clientJSFiles = new Map;
|
|
12
14
|
async function buildClientJS() {
|
|
13
15
|
const sourceFile = resolve(PWD, "../", "./src/clientjs/hyperspan-client.ts");
|
|
14
16
|
const output = await Bun.build({
|
|
15
17
|
entrypoints: [sourceFile],
|
|
16
|
-
outdir: `./public
|
|
18
|
+
outdir: `./public/${CLIENTJS_PUBLIC_PATH}`,
|
|
17
19
|
naming: IS_PROD ? "[dir]/[name]-[hash].[ext]" : undefined,
|
|
18
20
|
minify: IS_PROD
|
|
19
21
|
});
|
|
20
22
|
const jsFile = output.outputs[0].path.split("/").reverse()[0];
|
|
21
|
-
clientJSFiles.set("_hs", { src:
|
|
23
|
+
clientJSFiles.set("_hs", { src: `${CLIENTJS_PUBLIC_PATH}/${jsFile}` });
|
|
24
|
+
}
|
|
25
|
+
function renderClientJS(module, onLoad) {
|
|
26
|
+
if (!module.__CLIENT_JS) {
|
|
27
|
+
throw new Error(`[Hyperspan] Client JS was not loaded by Hyperspan! Ensure the filename ends with .client.ts to use this render method.`);
|
|
28
|
+
}
|
|
29
|
+
return html.raw(module.__CLIENT_JS.renderScriptTag({
|
|
30
|
+
onLoad: onLoad ? functionToString(onLoad) : undefined
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
function functionToString(fn) {
|
|
34
|
+
let str = fn.toString().trim();
|
|
35
|
+
if (!str.includes("function ")) {
|
|
36
|
+
if (str.includes("async ")) {
|
|
37
|
+
str = "async function " + str.replace("async ", "");
|
|
38
|
+
} else {
|
|
39
|
+
str = "function " + str;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const lines = str.split(`
|
|
43
|
+
`);
|
|
44
|
+
const firstLine = lines[0];
|
|
45
|
+
const lastLine = lines[lines.length - 1];
|
|
46
|
+
if (!lastLine?.includes("}")) {
|
|
47
|
+
return str.replace("=> ", "{ return ") + "; }";
|
|
48
|
+
}
|
|
49
|
+
if (firstLine.includes("=>")) {
|
|
50
|
+
return str.replace("=> ", "");
|
|
51
|
+
}
|
|
52
|
+
return str;
|
|
22
53
|
}
|
|
23
54
|
var clientCSSFiles = new Map;
|
|
24
55
|
async function buildClientCSS() {
|
|
@@ -60,7 +91,6 @@ function hyperspanScriptTags() {
|
|
|
60
91
|
function assetHash(content) {
|
|
61
92
|
return createHash("md5").update(content).digest("hex");
|
|
62
93
|
}
|
|
63
|
-
var ISLAND_PUBLIC_PATH = "/_hs/js/islands";
|
|
64
94
|
var ISLAND_DEFAULTS = () => ({
|
|
65
95
|
ssr: true,
|
|
66
96
|
loading: undefined
|
|
@@ -73,8 +103,10 @@ function renderIsland(Component, props, options = ISLAND_DEFAULTS()) {
|
|
|
73
103
|
}
|
|
74
104
|
export {
|
|
75
105
|
renderIsland,
|
|
106
|
+
renderClientJS,
|
|
76
107
|
hyperspanStyleTags,
|
|
77
108
|
hyperspanScriptTags,
|
|
109
|
+
functionToString,
|
|
78
110
|
clientJSFiles,
|
|
79
111
|
clientImportMap,
|
|
80
112
|
clientCSSFiles,
|
|
@@ -82,6 +114,7 @@ export {
|
|
|
82
114
|
buildClientCSS,
|
|
83
115
|
assetHash,
|
|
84
116
|
ISLAND_PUBLIC_PATH,
|
|
85
|
-
ISLAND_DEFAULTS
|
|
117
|
+
ISLAND_DEFAULTS,
|
|
118
|
+
CLIENTJS_PUBLIC_PATH
|
|
86
119
|
};
|
|
87
120
|
|
package/dist/server.js
CHANGED
|
@@ -1,14 +1,80 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CLIENTJS_PUBLIC_PATH,
|
|
2
3
|
assetHash,
|
|
3
4
|
buildClientCSS,
|
|
4
|
-
buildClientJS
|
|
5
|
+
buildClientJS,
|
|
6
|
+
clientImportMap
|
|
5
7
|
} from "./assets.js";
|
|
6
8
|
import"./chunk-atw8cdg1.js";
|
|
7
9
|
|
|
10
|
+
// src/plugins.ts
|
|
11
|
+
var CLIENT_JS_CACHE = new Map;
|
|
12
|
+
var EXPORT_REGEX = /export\{(.*)\}/g;
|
|
13
|
+
async function clientJSPlugin(config) {
|
|
14
|
+
await Bun.plugin({
|
|
15
|
+
name: "Hyperspan Client JS Loader",
|
|
16
|
+
async setup(build) {
|
|
17
|
+
build.onLoad({ filter: /\.client\.ts$/ }, async (args) => {
|
|
18
|
+
const jsId = assetHash(args.path);
|
|
19
|
+
if (CLIENT_JS_CACHE.has(jsId)) {
|
|
20
|
+
return {
|
|
21
|
+
contents: CLIENT_JS_CACHE.get(jsId) || "",
|
|
22
|
+
loader: "js"
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const result = await Bun.build({
|
|
26
|
+
entrypoints: [args.path],
|
|
27
|
+
outdir: `./public/${CLIENTJS_PUBLIC_PATH}`,
|
|
28
|
+
naming: IS_PROD ? "[dir]/[name]-[hash].[ext]" : undefined,
|
|
29
|
+
external: Array.from(clientImportMap.keys()),
|
|
30
|
+
minify: true,
|
|
31
|
+
format: "esm",
|
|
32
|
+
target: "browser"
|
|
33
|
+
});
|
|
34
|
+
const esmName = String(result.outputs[0].path.split("/").reverse()[0]).replace(".js", "");
|
|
35
|
+
clientImportMap.set(esmName, `${CLIENTJS_PUBLIC_PATH}/${esmName}.js`);
|
|
36
|
+
const contents = await result.outputs[0].text();
|
|
37
|
+
const exportLine = EXPORT_REGEX.exec(contents);
|
|
38
|
+
let exports = "{}";
|
|
39
|
+
if (exportLine) {
|
|
40
|
+
const exportName = exportLine[1];
|
|
41
|
+
exports = "{" + exportName.split(",").map((name) => name.trim().split(" as ")).map(([name, alias]) => `${alias === "default" ? "default as " + name : alias}`).join(", ") + "}";
|
|
42
|
+
}
|
|
43
|
+
const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, "$1: $2");
|
|
44
|
+
const moduleCode = `// hyperspan:processed
|
|
45
|
+
import { functionToString } from '@hyperspan/framework/assets';
|
|
46
|
+
|
|
47
|
+
// Original file contents
|
|
48
|
+
${contents}
|
|
49
|
+
|
|
50
|
+
// hyperspan:client-js-plugin
|
|
51
|
+
export const __CLIENT_JS = {
|
|
52
|
+
id: "${jsId}",
|
|
53
|
+
esmName: "${esmName}",
|
|
54
|
+
sourceFile: "${args.path}",
|
|
55
|
+
outputFile: "${result.outputs[0].path}",
|
|
56
|
+
renderScriptTag: ({ onLoad }) => {
|
|
57
|
+
const fn = onLoad ? functionToString(onLoad) : undefined;
|
|
58
|
+
return \`<script type="module" data-source-id="${jsId}">import ${exports} from "${esmName}";
|
|
59
|
+
\${fn ? \`const fn = \${fn};
|
|
60
|
+
fn(${fnArgs});\` : ''}</script>\`;
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
CLIENT_JS_CACHE.set(jsId, moduleCode);
|
|
65
|
+
return {
|
|
66
|
+
contents: moduleCode,
|
|
67
|
+
loader: "js"
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
8
74
|
// src/server.ts
|
|
75
|
+
import { html, isHSHtml, renderStream, renderAsync, render } from "@hyperspan/html";
|
|
9
76
|
import { readdir } from "node:fs/promises";
|
|
10
77
|
import { basename, extname, join } from "node:path";
|
|
11
|
-
import { html, isHSHtml, renderStream, renderAsync, render } from "@hyperspan/html";
|
|
12
78
|
|
|
13
79
|
// ../../node_modules/isbot/index.mjs
|
|
14
80
|
var fullPattern = " daum[ /]| deusu/| yadirectfetcher|(?:^|[^g])news(?!sapphire)|(?<! (?:channel/|google/))google(?!(app|/google| pixel))|(?<! cu)bots?(?:\\b|_)|(?<!(?:lib))http|(?<![hg]m)score|(?<!cam)scan|@[a-z][\\w-]+\\.|\\(\\)|\\.com\\b|\\btime/|\\||^<|^[\\w \\.\\-\\(?:\\):%]+(?:/v?\\d+(?:\\.\\d+)?(?:\\.\\d{1,10})*?)?(?:,|$)|^[^ ]{50,}$|^\\d+\\b|^\\w*search\\b|^\\w+/[\\w\\(\\)]*$|^active|^ad muncher|^amaya|^avsdevicesdk/|^biglotron|^bot|^bw/|^clamav[ /]|^client/|^cobweb/|^custom|^ddg[_-]android|^discourse|^dispatch/\\d|^downcast/|^duckduckgo|^email|^facebook|^getright/|^gozilla/|^hobbit|^hotzonu|^hwcdn/|^igetter/|^jeode/|^jetty/|^jigsaw|^microsoft bits|^movabletype|^mozilla/\\d\\.\\d\\s[\\w\\.-]+$|^mozilla/\\d\\.\\d\\s\\(compatible;?(?:\\s\\w+\\/\\d+\\.\\d+)?\\)$|^navermailapp|^netsurf|^offline|^openai/|^owler|^php|^postman|^python|^rank|^read|^reed|^rest|^rss|^snapchat|^space bison|^svn|^swcd |^taringa|^thumbor/|^track|^w3c|^webbandit/|^webcopier|^wget|^whatsapp|^wordpress|^xenu link sleuth|^yahoo|^yandex|^zdm/\\d|^zoom marketplace/|^{{.*}}$|analyzer|archive|ask jeeves/teoma|audit|bit\\.ly/|bluecoat drtr|browsex|burpcollaborator|capture|catch|check\\b|checker|chrome-lighthouse|chromeframe|classifier|cloudflare|convertify|crawl|cypress/|dareboost|datanyze|dejaclick|detect|dmbrowser|download|evc-batch/|exaleadcloudview|feed|firephp|functionize|gomezagent|grab|headless|httrack|hubspot marketing grader|hydra|ibisbrowser|infrawatch|insight|inspect|iplabel|ips-agent|java(?!;)|library|linkcheck|mail\\.ru/|manager|measure|neustar wpm|node|nutch|offbyone|onetrust|optimize|pageburst|pagespeed|parser|perl|phantomjs|pingdom|powermarks|preview|proxy|ptst[ /]\\d|retriever|rexx;|rigor|rss\\b|scrape|server|sogou|sparkler/|speedcurve|spider|splash|statuscake|supercleaner|synapse|synthetic|tools|torrent|transcoder|url|validator|virtuoso|wappalyzer|webglance|webkit2png|whatcms/|xtate/";
|
|
@@ -1858,8 +1924,25 @@ function createRoute(handler) {
|
|
|
1858
1924
|
..._middleware,
|
|
1859
1925
|
async (context) => {
|
|
1860
1926
|
const method = context.req.method.toUpperCase();
|
|
1927
|
+
if (method === "OPTIONS") {
|
|
1928
|
+
return context.html(render(html`
|
|
1929
|
+
<!DOCTYPE html>
|
|
1930
|
+
<html lang="en"></html>
|
|
1931
|
+
`), {
|
|
1932
|
+
status: 200,
|
|
1933
|
+
headers: {
|
|
1934
|
+
"Access-Control-Allow-Origin": "*",
|
|
1935
|
+
"Access-Control-Allow-Methods": [
|
|
1936
|
+
"HEAD",
|
|
1937
|
+
"OPTIONS",
|
|
1938
|
+
...Object.keys(_handlers)
|
|
1939
|
+
].join(", "),
|
|
1940
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
1941
|
+
}
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1861
1944
|
return returnHTMLResponse(context, () => {
|
|
1862
|
-
const handler2 = _handlers[method];
|
|
1945
|
+
const handler2 = method === "HEAD" ? _handlers["GET"] : _handlers[method];
|
|
1863
1946
|
if (!handler2) {
|
|
1864
1947
|
throw new HTTPException(405, { message: "Method not allowed" });
|
|
1865
1948
|
}
|
|
@@ -1908,7 +1991,24 @@ function createAPIRoute(handler) {
|
|
|
1908
1991
|
..._middleware,
|
|
1909
1992
|
async (context) => {
|
|
1910
1993
|
const method = context.req.method.toUpperCase();
|
|
1911
|
-
|
|
1994
|
+
if (method === "OPTIONS") {
|
|
1995
|
+
return context.json({
|
|
1996
|
+
meta: { success: true, dtResponse: new Date },
|
|
1997
|
+
data: {}
|
|
1998
|
+
}, {
|
|
1999
|
+
status: 200,
|
|
2000
|
+
headers: {
|
|
2001
|
+
"Access-Control-Allow-Origin": "*",
|
|
2002
|
+
"Access-Control-Allow-Methods": [
|
|
2003
|
+
"HEAD",
|
|
2004
|
+
"OPTIONS",
|
|
2005
|
+
...Object.keys(_handlers)
|
|
2006
|
+
].join(", "),
|
|
2007
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
const handler2 = method === "HEAD" ? _handlers["GET"] : _handlers[method];
|
|
1912
2012
|
if (!handler2) {
|
|
1913
2013
|
return context.json({
|
|
1914
2014
|
meta: { success: false, dtResponse: new Date },
|
|
@@ -2090,7 +2190,7 @@ function createRouteFromModule(RouteModule) {
|
|
|
2090
2190
|
return route._getRouteHandlers();
|
|
2091
2191
|
}
|
|
2092
2192
|
async function createServer(config) {
|
|
2093
|
-
await Promise.all([buildClientJS(), buildClientCSS()]);
|
|
2193
|
+
await Promise.all([buildClientJS(), buildClientCSS(), clientJSPlugin(config)]);
|
|
2094
2194
|
const app = new Hono2;
|
|
2095
2195
|
config.beforeRoutesAdded && config.beforeRoutesAdded(app);
|
|
2096
2196
|
const [routes, actions] = await Promise.all([buildRoutes(config), buildActions(config)]);
|
package/package.json
CHANGED
package/src/assets.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type THSIslandOptions = {
|
|
|
11
11
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
12
12
|
const PWD = import.meta.dir;
|
|
13
13
|
|
|
14
|
+
export const CLIENTJS_PUBLIC_PATH = '/_hs/js';
|
|
15
|
+
export const ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
14
16
|
export const clientImportMap = new Map<string, string>();
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -21,14 +23,66 @@ export async function buildClientJS() {
|
|
|
21
23
|
const sourceFile = resolve(PWD, '../', './src/clientjs/hyperspan-client.ts');
|
|
22
24
|
const output = await Bun.build({
|
|
23
25
|
entrypoints: [sourceFile],
|
|
24
|
-
outdir: `./public
|
|
26
|
+
outdir: `./public/${CLIENTJS_PUBLIC_PATH}`,
|
|
25
27
|
naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
|
|
26
28
|
minify: IS_PROD,
|
|
27
29
|
});
|
|
28
30
|
|
|
29
31
|
const jsFile = output.outputs[0].path.split('/').reverse()[0];
|
|
30
32
|
|
|
31
|
-
clientJSFiles.set('_hs', { src:
|
|
33
|
+
clientJSFiles.set('_hs', { src: `${CLIENTJS_PUBLIC_PATH}/${jsFile}` });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render a client JS module as a script tag
|
|
38
|
+
*/
|
|
39
|
+
export function renderClientJS<T>(module: T, onLoad?: (module: T) => void) {
|
|
40
|
+
// @ts-ignore
|
|
41
|
+
if (!module.__CLIENT_JS) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[Hyperspan] Client JS was not loaded by Hyperspan! Ensure the filename ends with .client.ts to use this render method.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return html.raw(
|
|
48
|
+
// @ts-ignore
|
|
49
|
+
module.__CLIENT_JS.renderScriptTag({
|
|
50
|
+
onLoad: onLoad ? functionToString(onLoad) : undefined,
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Convert a function to a string (results in loss of context!)
|
|
57
|
+
* Handles named, async, and arrow functions
|
|
58
|
+
*/
|
|
59
|
+
export function functionToString(fn: any) {
|
|
60
|
+
let str = fn.toString().trim();
|
|
61
|
+
|
|
62
|
+
// Ensure consistent output & handle async
|
|
63
|
+
if (!str.includes('function ')) {
|
|
64
|
+
if (str.includes('async ')) {
|
|
65
|
+
str = 'async function ' + str.replace('async ', '');
|
|
66
|
+
} else {
|
|
67
|
+
str = 'function ' + str;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lines = str.split('\n');
|
|
72
|
+
const firstLine = lines[0];
|
|
73
|
+
const lastLine = lines[lines.length - 1];
|
|
74
|
+
|
|
75
|
+
// Arrow function conversion
|
|
76
|
+
if (!lastLine?.includes('}')) {
|
|
77
|
+
return str.replace('=> ', '{ return ') + '; }';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Cleanup arrow function
|
|
81
|
+
if (firstLine.includes('=>')) {
|
|
82
|
+
return str.replace('=> ', '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return str;
|
|
32
86
|
}
|
|
33
87
|
|
|
34
88
|
/**
|
|
@@ -101,7 +155,6 @@ export function assetHash(content: string): string {
|
|
|
101
155
|
/**
|
|
102
156
|
* Island defaults
|
|
103
157
|
*/
|
|
104
|
-
export const ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
105
158
|
export const ISLAND_DEFAULTS: () => THSIslandOptions = () => ({
|
|
106
159
|
ssr: true,
|
|
107
160
|
loading: undefined,
|
|
@@ -12,28 +12,46 @@ function htmlAsyncContentObserver() {
|
|
|
12
12
|
const asyncContent = list
|
|
13
13
|
.map((mutation) =>
|
|
14
14
|
Array.from(mutation.addedNodes).find((node: any) => {
|
|
15
|
-
|
|
15
|
+
if (!node) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
|
|
16
19
|
})
|
|
17
20
|
)
|
|
18
21
|
.filter((node: any) => node);
|
|
19
22
|
|
|
20
|
-
asyncContent.forEach((
|
|
23
|
+
asyncContent.forEach((templateEl: any) => {
|
|
21
24
|
try {
|
|
22
|
-
// Also observe
|
|
23
|
-
asyncContentObserver.observe(
|
|
25
|
+
// Also observe for content inside the template content (shadow DOM is separate)
|
|
26
|
+
asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
|
|
24
27
|
|
|
25
|
-
const slotId =
|
|
28
|
+
const slotId = templateEl.id.replace('_content', '');
|
|
26
29
|
const slotEl = document.getElementById(slotId);
|
|
27
30
|
|
|
28
31
|
if (slotEl) {
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// Content AND slot are present - let's insert the content into the slot
|
|
33
|
+
// Ensure the content is fully done streaming in before inserting it into the slot
|
|
34
|
+
waitForContent(templateEl.content, (el2) => {
|
|
35
|
+
return Array.from(el2.childNodes).find(
|
|
36
|
+
(node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
|
|
37
|
+
);
|
|
38
|
+
})
|
|
39
|
+
.then((endComment) => {
|
|
40
|
+
templateEl.content.removeChild(endComment);
|
|
41
|
+
const content = templateEl.content.cloneNode(true);
|
|
42
|
+
Idiomorph.morph(slotEl, content);
|
|
43
|
+
templateEl.parentNode.removeChild(templateEl);
|
|
44
|
+
lazyLoadScripts();
|
|
45
|
+
})
|
|
46
|
+
.catch(console.error);
|
|
47
|
+
} else {
|
|
48
|
+
// Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
|
|
49
|
+
waitForContent(document.body, () => {
|
|
50
|
+
return document.getElementById(slotId);
|
|
51
|
+
}).then((slotEl) => {
|
|
52
|
+
Idiomorph.morph(slotEl, templateEl.content.cloneNode(true));
|
|
53
|
+
lazyLoadScripts();
|
|
33
54
|
});
|
|
34
|
-
|
|
35
|
-
// Lazy load scripts (if any) after the content is inserted
|
|
36
|
-
lazyLoadScripts();
|
|
37
55
|
}
|
|
38
56
|
} catch (e) {
|
|
39
57
|
console.error(e);
|
|
@@ -49,18 +67,29 @@ htmlAsyncContentObserver();
|
|
|
49
67
|
* Wait until ALL of the content inside an element is present from streaming in.
|
|
50
68
|
* Large chunks of content can sometimes take more than a single tick to write to DOM.
|
|
51
69
|
*/
|
|
52
|
-
async function
|
|
53
|
-
|
|
70
|
+
async function waitForContent(
|
|
71
|
+
el: HTMLElement,
|
|
72
|
+
waitFn: (
|
|
73
|
+
node: HTMLElement
|
|
74
|
+
) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
|
|
75
|
+
options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
|
|
76
|
+
): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
let timeout: NodeJS.Timeout;
|
|
54
79
|
const interval = setInterval(() => {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
const content = waitFn(el);
|
|
81
|
+
if (content) {
|
|
82
|
+
if (timeout) {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
}
|
|
60
85
|
clearInterval(interval);
|
|
61
|
-
resolve(
|
|
86
|
+
resolve(content);
|
|
62
87
|
}
|
|
63
|
-
},
|
|
88
|
+
}, options.intervalMs || 20);
|
|
89
|
+
timeout = setTimeout(() => {
|
|
90
|
+
clearInterval(interval);
|
|
91
|
+
reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
|
|
92
|
+
}, options.timeoutMs || 10000);
|
|
64
93
|
});
|
|
65
94
|
}
|
|
66
95
|
|
|
@@ -74,6 +103,7 @@ class HSAction extends HTMLElement {
|
|
|
74
103
|
|
|
75
104
|
connectedCallback() {
|
|
76
105
|
actionFormObserver.observe(this, { childList: true, subtree: true });
|
|
106
|
+
bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
|
|
77
107
|
}
|
|
78
108
|
}
|
|
79
109
|
window.customElements.define('hs-action', HSAction);
|
|
@@ -91,6 +121,10 @@ const actionFormObserver = new MutationObserver((list) => {
|
|
|
91
121
|
* Bind the form inside an hs-action element to the action URL and submit handler
|
|
92
122
|
*/
|
|
93
123
|
function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
|
|
124
|
+
if (!hsActionElement || !form) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
form.setAttribute('action', hsActionElement.getAttribute('url') || '');
|
|
95
129
|
const submitHandler = (e: Event) => {
|
|
96
130
|
formSubmitToRoute(e, form as HTMLFormElement, {
|
|
@@ -116,8 +150,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
116
150
|
'X-Request-Type': 'partial',
|
|
117
151
|
};
|
|
118
152
|
|
|
119
|
-
let response: Response;
|
|
120
|
-
|
|
121
153
|
const hsActionTag = form.closest('hs-action');
|
|
122
154
|
const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
|
|
123
155
|
if (submitBtn) {
|
|
@@ -136,7 +168,6 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
136
168
|
return '';
|
|
137
169
|
}
|
|
138
170
|
|
|
139
|
-
response = res;
|
|
140
171
|
return res.text();
|
|
141
172
|
})
|
|
142
173
|
.then((content: string) => {
|
|
@@ -149,6 +180,7 @@ function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOpt
|
|
|
149
180
|
|
|
150
181
|
Idiomorph.morph(target, content);
|
|
151
182
|
opts.afterResponse && opts.afterResponse();
|
|
183
|
+
lazyLoadScripts();
|
|
152
184
|
});
|
|
153
185
|
}
|
|
154
186
|
|
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { assetHash, CLIENTJS_PUBLIC_PATH, clientImportMap, clientJSFiles } from './assets';
|
|
2
|
+
import { IS_PROD, type THSServerConfig } from './server';
|
|
3
|
+
|
|
4
|
+
const CLIENT_JS_CACHE = new Map<string, string>();
|
|
5
|
+
const EXPORT_REGEX = /export\{(.*)\}/g;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hyperspan Client JS Plugin
|
|
9
|
+
*/
|
|
10
|
+
export async function clientJSPlugin(config: THSServerConfig) {
|
|
11
|
+
// Define a Bun plugin to handle .client.ts files
|
|
12
|
+
await Bun.plugin({
|
|
13
|
+
name: 'Hyperspan Client JS Loader',
|
|
14
|
+
async setup(build) {
|
|
15
|
+
// when a .client.ts file is imported...
|
|
16
|
+
build.onLoad({ filter: /\.client\.ts$/ }, async (args) => {
|
|
17
|
+
const jsId = assetHash(args.path);
|
|
18
|
+
|
|
19
|
+
// Cache: Avoid re-processing the same file
|
|
20
|
+
if (CLIENT_JS_CACHE.has(jsId)) {
|
|
21
|
+
return {
|
|
22
|
+
contents: CLIENT_JS_CACHE.get(jsId) || '',
|
|
23
|
+
loader: 'js',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// We need to build the file to ensure we can ship it to the client with dependencies
|
|
28
|
+
// Ironic, right? Calling Bun.build() inside of a plugin that runs on Bun.build()?
|
|
29
|
+
const result = await Bun.build({
|
|
30
|
+
entrypoints: [args.path],
|
|
31
|
+
outdir: `./public/${CLIENTJS_PUBLIC_PATH}`,
|
|
32
|
+
naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
|
|
33
|
+
external: Array.from(clientImportMap.keys()),
|
|
34
|
+
minify: true,
|
|
35
|
+
format: 'esm',
|
|
36
|
+
target: 'browser',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Add output file to import map
|
|
40
|
+
const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
41
|
+
clientImportMap.set(esmName, `${CLIENTJS_PUBLIC_PATH}/${esmName}.js`);
|
|
42
|
+
|
|
43
|
+
const contents = await result.outputs[0].text();
|
|
44
|
+
const exportLine = EXPORT_REGEX.exec(contents);
|
|
45
|
+
|
|
46
|
+
let exports = '{}';
|
|
47
|
+
if (exportLine) {
|
|
48
|
+
const exportName = exportLine[1];
|
|
49
|
+
exports =
|
|
50
|
+
'{' +
|
|
51
|
+
exportName
|
|
52
|
+
.split(',')
|
|
53
|
+
.map((name) => name.trim().split(' as '))
|
|
54
|
+
.map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
|
|
55
|
+
.join(', ') +
|
|
56
|
+
'}';
|
|
57
|
+
}
|
|
58
|
+
const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
|
|
59
|
+
|
|
60
|
+
// Export a special object that can be used to render the client JS as a script tag
|
|
61
|
+
const moduleCode = `// hyperspan:processed
|
|
62
|
+
import { functionToString } from '@hyperspan/framework/assets';
|
|
63
|
+
|
|
64
|
+
// Original file contents
|
|
65
|
+
${contents}
|
|
66
|
+
|
|
67
|
+
// hyperspan:client-js-plugin
|
|
68
|
+
export const __CLIENT_JS = {
|
|
69
|
+
id: "${jsId}",
|
|
70
|
+
esmName: "${esmName}",
|
|
71
|
+
sourceFile: "${args.path}",
|
|
72
|
+
outputFile: "${result.outputs[0].path}",
|
|
73
|
+
renderScriptTag: ({ onLoad }) => {
|
|
74
|
+
const fn = onLoad ? functionToString(onLoad) : undefined;
|
|
75
|
+
return \`<script type="module" data-source-id="${jsId}">import ${exports} from "${esmName}";\n\${fn ? \`const fn = \${fn};\nfn(${fnArgs});\` : ''}</script>\`;
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
CLIENT_JS_CACHE.set(jsId, moduleCode);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
contents: moduleCode,
|
|
84
|
+
loader: 'js',
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { buildClientJS, buildClientCSS, assetHash } from './assets';
|
|
2
|
+
import { clientJSPlugin } from './plugins';
|
|
3
|
+
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
|
|
1
4
|
import { readdir } from 'node:fs/promises';
|
|
2
5
|
import { basename, extname, join } from 'node:path';
|
|
3
|
-
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
|
|
4
6
|
import { isbot } from 'isbot';
|
|
5
|
-
import { buildClientJS, buildClientCSS, assetHash } from './assets';
|
|
6
7
|
import { Hono, type Context } from 'hono';
|
|
7
8
|
import { serveStatic } from 'hono/bun';
|
|
8
9
|
import { HTTPException } from 'hono/http-exception';
|
|
@@ -83,8 +84,32 @@ export function createRoute(handler?: THSRouteHandler): THSRoute {
|
|
|
83
84
|
async (context: Context) => {
|
|
84
85
|
const method = context.req.method.toUpperCase();
|
|
85
86
|
|
|
87
|
+
// Handle CORS preflight requests
|
|
88
|
+
if (method === 'OPTIONS') {
|
|
89
|
+
return context.html(
|
|
90
|
+
render(html`
|
|
91
|
+
<!DOCTYPE html>
|
|
92
|
+
<html lang="en"></html>
|
|
93
|
+
`),
|
|
94
|
+
{
|
|
95
|
+
status: 200,
|
|
96
|
+
headers: {
|
|
97
|
+
'Access-Control-Allow-Origin': '*',
|
|
98
|
+
'Access-Control-Allow-Methods': [
|
|
99
|
+
'HEAD',
|
|
100
|
+
'OPTIONS',
|
|
101
|
+
...Object.keys(_handlers),
|
|
102
|
+
].join(', '),
|
|
103
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Handle other requests, HEAD is GET with no body
|
|
86
110
|
return returnHTMLResponse(context, () => {
|
|
87
|
-
const handler = _handlers[method];
|
|
111
|
+
const handler = method === 'HEAD' ? _handlers['GET'] : _handlers[method];
|
|
112
|
+
|
|
88
113
|
if (!handler) {
|
|
89
114
|
throw new HTTPException(405, { message: 'Method not allowed' });
|
|
90
115
|
}
|
|
@@ -142,7 +167,31 @@ export function createAPIRoute(handler?: THSAPIRouteHandler): THSAPIRoute {
|
|
|
142
167
|
..._middleware,
|
|
143
168
|
async (context: Context) => {
|
|
144
169
|
const method = context.req.method.toUpperCase();
|
|
145
|
-
|
|
170
|
+
|
|
171
|
+
// Handle CORS preflight requests
|
|
172
|
+
if (method === 'OPTIONS') {
|
|
173
|
+
return context.json(
|
|
174
|
+
{
|
|
175
|
+
meta: { success: true, dtResponse: new Date() },
|
|
176
|
+
data: {},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
status: 200,
|
|
180
|
+
headers: {
|
|
181
|
+
'Access-Control-Allow-Origin': '*',
|
|
182
|
+
'Access-Control-Allow-Methods': [
|
|
183
|
+
'HEAD',
|
|
184
|
+
'OPTIONS',
|
|
185
|
+
...Object.keys(_handlers),
|
|
186
|
+
].join(', '),
|
|
187
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Handle other requests, HEAD is GET with no body
|
|
194
|
+
const handler = method === 'HEAD' ? _handlers['GET'] : _handlers[method];
|
|
146
195
|
|
|
147
196
|
if (!handler) {
|
|
148
197
|
return context.json(
|
|
@@ -456,7 +505,7 @@ export function createRouteFromModule(
|
|
|
456
505
|
*/
|
|
457
506
|
export async function createServer(config: THSServerConfig): Promise<Hono> {
|
|
458
507
|
// Build client JS and CSS bundles so they are available for templates when streaming starts
|
|
459
|
-
await Promise.all([buildClientJS(), buildClientCSS()]);
|
|
508
|
+
await Promise.all([buildClientJS(), buildClientCSS(), clientJSPlugin(config)]);
|
|
460
509
|
|
|
461
510
|
const app = new Hono();
|
|
462
511
|
|