@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 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/_hs/js`,
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: "/_hs/js/" + jsFile });
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
- const handler2 = _handlers[method];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "dist/server.ts",
6
6
  "types": "src/server.ts",
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/_hs/js`,
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: '/_hs/js/' + jsFile });
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
- return node.id?.startsWith('async_') && node.id?.endsWith('_content');
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((el: any) => {
23
+ asyncContent.forEach((templateEl: any) => {
21
24
  try {
22
- // Also observe child nodes for nested async content
23
- asyncContentObserver.observe(el.content, { childList: true, subtree: true });
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 = el.id.replace('_content', '');
28
+ const slotId = templateEl.id.replace('_content', '');
26
29
  const slotEl = document.getElementById(slotId);
27
30
 
28
31
  if (slotEl) {
29
- // Only insert the content if it is done streaming in
30
- waitForEndContent(el.content).then(() => {
31
- Idiomorph.morph(slotEl, el.content.cloneNode(true));
32
- el.parentNode.removeChild(el);
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 waitForEndContent(el: HTMLElement) {
53
- return new Promise((resolve) => {
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 endComment = Array.from(el.childNodes).find((node) => {
56
- return node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end';
57
- });
58
- if (endComment) {
59
- el.removeChild(endComment);
80
+ const content = waitFn(el);
81
+ if (content) {
82
+ if (timeout) {
83
+ clearTimeout(timeout);
84
+ }
60
85
  clearInterval(interval);
61
- resolve(true);
86
+ resolve(content);
62
87
  }
63
- }, 10);
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
- const handler = _handlers[method];
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