@hyperspan/framework 1.0.0-alpha.12 → 1.0.0-alpha.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.0-alpha.12",
3
+ "version": "1.0.0-alpha.14",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -37,10 +37,6 @@
37
37
  "types": "./src/client/js.ts",
38
38
  "default": "./src/client/js.ts"
39
39
  },
40
- "./plugins": {
41
- "types": "./src/plugins.ts",
42
- "default": "./src/plugins.ts"
43
- },
44
40
  "./actions": {
45
41
  "types": "./src/actions.ts",
46
42
  "default": "./src/actions.ts"
@@ -1,68 +1,5 @@
1
- import { Idiomorph } from './idiomorph';
2
1
  import { lazyLoadScripts } from './hyperspan-scripts.client';
3
2
 
4
- /**
5
- * Used for streaming content from the server to the client.
6
- */
7
- function htmlAsyncContentObserver() {
8
- if (typeof MutationObserver != 'undefined') {
9
- // Hyperspan - Async content loader
10
- // Puts streamed content in its place immediately after it is added to the DOM
11
- const asyncContentObserver = new MutationObserver((list) => {
12
- const asyncContent = list
13
- .map((mutation) =>
14
- Array.from(mutation.addedNodes).find((node: any) => {
15
- if (!node || !node?.id || typeof node.id !== 'string') {
16
- return false;
17
- }
18
- return node.id?.startsWith('async_loading_') && node.id?.endsWith('_content');
19
- })
20
- )
21
- .filter((node: any) => node);
22
-
23
- asyncContent.forEach((templateEl: any) => {
24
- try {
25
- // Also observe for content inside the template content (shadow DOM is separate)
26
- asyncContentObserver.observe(templateEl.content, { childList: true, subtree: true });
27
-
28
- const slotId = templateEl.id.replace('_content', '');
29
- const slotEl = document.getElementById(slotId);
30
-
31
- if (slotEl) {
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();
54
- });
55
- }
56
- } catch (e) {
57
- console.error(e);
58
- }
59
- });
60
- });
61
- asyncContentObserver.observe(document.body, { childList: true, subtree: true });
62
- }
63
- }
64
- htmlAsyncContentObserver();
65
-
66
3
  /**
67
4
  * Wait until ALL of the content inside an element is present from streaming in.
68
5
  * Large chunks of content can sometimes take more than a single tick to write to DOM.
@@ -91,4 +28,39 @@ async function waitForContent(
91
28
  reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
92
29
  }, options.timeoutMs || 10000);
93
30
  });
94
- }
31
+ }
32
+
33
+ function renderStreamChunk(chunk: { id: string }) {
34
+ const slotId = chunk.id;
35
+ const slotEl = document.getElementById(slotId);
36
+ const templateEl = document.getElementById(`${slotId}_content`) as HTMLTemplateElement;
37
+
38
+ if (slotEl) {
39
+ // Content AND slot are present - let's insert the content into the slot
40
+ // Ensure the content is fully done streaming in before inserting it into the slot
41
+ waitForContent(templateEl.content as unknown as HTMLElement, (el2) => {
42
+ return Array.from(el2.childNodes).find(
43
+ (node) => node.nodeType === Node.COMMENT_NODE && node.nodeValue === 'end'
44
+ );
45
+ })
46
+ .then((endComment) => {
47
+ templateEl.content.removeChild(endComment as Node);
48
+ const content = templateEl.content.cloneNode(true);
49
+ slotEl.replaceWith(content);
50
+ templateEl.parentNode?.removeChild(templateEl);
51
+ lazyLoadScripts();
52
+ })
53
+ .catch(console.error);
54
+ } else {
55
+ // Slot is NOT present - wait for it to be added to the DOM so we can insert the content into it
56
+ waitForContent(document.body, () => {
57
+ return document.getElementById(slotId);
58
+ }).then((slotEl) => {
59
+ (slotEl as HTMLElement)?.replaceWith(templateEl.content.cloneNode(true));
60
+ lazyLoadScripts();
61
+ });
62
+ }
63
+ }
64
+
65
+ // @ts-ignore
66
+ window._hscc = renderStreamChunk;
@@ -0,0 +1,200 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { functionToString } from './js';
3
+
4
+ describe('functionToString', () => {
5
+ describe('named functions', () => {
6
+ test('converts named function to string', () => {
7
+ function myFunction() {
8
+ return 'hello';
9
+ }
10
+
11
+ const result = functionToString(myFunction);
12
+ expect(result).toContain('function');
13
+ expect(result).toContain('myFunction');
14
+ expect(result).toContain("return 'hello'");
15
+ });
16
+
17
+ test('converts named function with parameters', () => {
18
+ function add(a: number, b: number) {
19
+ return a + b;
20
+ }
21
+
22
+ const result = functionToString(add);
23
+ expect(result).toContain('function');
24
+ expect(result).toContain('add');
25
+ expect(result).toContain('a');
26
+ expect(result).toContain('b');
27
+ });
28
+
29
+ test('converts named async function', () => {
30
+ async function fetchData() {
31
+ const response = await fetch('/api/data');
32
+ return response.json();
33
+ }
34
+
35
+ const result = functionToString(fetchData);
36
+ expect(result).toContain('async function');
37
+ expect(result).toContain('fetchData');
38
+ expect(result).toContain('await');
39
+ });
40
+ });
41
+
42
+ describe('anonymous functions', () => {
43
+ test('converts anonymous function to string', () => {
44
+ const fn = function () {
45
+ return 'anonymous';
46
+ };
47
+
48
+ const result = functionToString(fn);
49
+ expect(result).toContain('function');
50
+ expect(result).toContain("return 'anonymous'");
51
+ });
52
+
53
+ test('converts anonymous function with parameters', () => {
54
+ const fn = function (x: number, y: number) {
55
+ return x * y;
56
+ };
57
+
58
+ const result = functionToString(fn);
59
+ expect(result).toContain('function');
60
+ expect(result).toContain('x');
61
+ expect(result).toContain('y');
62
+ });
63
+
64
+ test('converts anonymous async function', () => {
65
+ const fn = async function () {
66
+ await new Promise(resolve => setTimeout(resolve, 100));
67
+ return 'done';
68
+ };
69
+
70
+ const result = functionToString(fn);
71
+ expect(result).toContain('async function');
72
+ expect(result).toContain('await');
73
+ });
74
+ });
75
+
76
+ describe('arrow functions', () => {
77
+ test('converts single-line arrow function without braces', () => {
78
+ const fn = (x: number) => x * 2;
79
+
80
+ const result = functionToString(fn);
81
+ expect(result).toContain('function(x) { return x * 2; }');
82
+ });
83
+
84
+ test('converts single-line arrow function with single parameter', () => {
85
+ const fn = (name: string) => `Hello, ${name}!`;
86
+
87
+ const result = functionToString(fn);
88
+ expect(result).toContain('function(name) { return `Hello, ${name}!`; }');
89
+ });
90
+
91
+ test('converts single-line arrow function with multiple parameters', () => {
92
+ const fn = (a: number, b: number) => a + b;
93
+
94
+ const result = functionToString(fn);
95
+ expect(result).toContain('function(a, b) { return a + b; }');
96
+ });
97
+
98
+ test('converts arrow function with braces', () => {
99
+ const fn = (x: number) => {
100
+ const doubled = x * 2;
101
+ return doubled;
102
+ };
103
+
104
+ const result = functionToString(fn);
105
+ expect(result).toContain('function');
106
+ expect(result).toContain('x');
107
+ expect(result).toContain('doubled');
108
+ });
109
+
110
+ test('converts multi-line arrow function', () => {
111
+ const fn = (items: string[]) => {
112
+ const filtered = items.filter(item => item.length > 0);
113
+ return filtered.map(item => item.toUpperCase());
114
+ };
115
+
116
+ const result = functionToString(fn);
117
+ expect(result).toContain('function');
118
+ expect(result).toContain('items');
119
+ expect(result).toContain('filtered');
120
+ });
121
+
122
+ test('converts async arrow function without braces', () => {
123
+ const fn = async (id: number) => await fetch(`/api/${id}`);
124
+
125
+ const result = functionToString(fn);
126
+ expect(result).toContain('async function');
127
+ expect(result).toContain('id');
128
+ expect(result).toContain('await');
129
+ });
130
+
131
+ test('converts async arrow function with braces', () => {
132
+ const fn = async (id: number) => {
133
+ const response = await fetch(`/api/${id}`);
134
+ return response.json();
135
+ };
136
+
137
+ const result = functionToString(fn);
138
+ expect(result).toContain('async function');
139
+ expect(result).toContain('id');
140
+ expect(result).toContain('await');
141
+ });
142
+
143
+ test('converts arrow function with no parameters', () => {
144
+ const fn = () => 'no params';
145
+
146
+ const result = functionToString(fn);
147
+ expect(result).toContain('function');
148
+ expect(result).toContain("return 'no params'");
149
+ });
150
+
151
+ test('converts arrow function with complex expression', () => {
152
+ const fn = (obj: { x: number; y: number }) => obj.x + obj.y;
153
+
154
+ const result = functionToString(fn);
155
+ expect(result).toContain('function');
156
+ expect(result).toContain('return');
157
+ expect(result).toContain('obj.x + obj.y');
158
+ });
159
+ });
160
+
161
+ describe('edge cases', () => {
162
+ test('handles function with whitespace', () => {
163
+ const fn = function () {
164
+ return 'test';
165
+ };
166
+
167
+ const result = functionToString(fn);
168
+ expect(result).toContain('function');
169
+ expect(result).toContain("return 'test'");
170
+ });
171
+
172
+ test('handles arrow function with whitespace', () => {
173
+ const fn = (x) => x * 2;
174
+
175
+ const result = functionToString(fn);
176
+ expect(result).toContain('function');
177
+ expect(result).toContain('return');
178
+ });
179
+
180
+ test('handles function with comments', () => {
181
+ const fn = function () {
182
+ // This is a comment
183
+ return 'commented';
184
+ };
185
+
186
+ const result = functionToString(fn);
187
+ expect(result).toContain('function');
188
+ expect(result).toContain("return 'commented'");
189
+ });
190
+
191
+ test('handles nested arrow functions', () => {
192
+ const fn = (arr: number[]) => arr.map(x => x * 2);
193
+
194
+ const result = functionToString(fn);
195
+ expect(result).toContain('function');
196
+ // The nested arrow function should also be converted
197
+ expect(result).toContain('x * 2');
198
+ });
199
+ });
200
+ });
package/src/client/js.ts CHANGED
@@ -1,30 +1,95 @@
1
- import { html } from '@hyperspan/html';
1
+ import { HSHtml, html } from '@hyperspan/html';
2
+ import { assetHash } from '../utils';
3
+ import { join } from 'node:path';
4
+
5
+ const CWD = process.cwd();
6
+ const IS_PROD = process.env.NODE_ENV === 'production';
2
7
 
3
8
  export const JS_PUBLIC_PATH = '/_hs/js';
4
9
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
5
10
  export const JS_IMPORT_MAP = new Map<string, string>();
11
+ const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
12
+ const EXPORT_REGEX = /export\{(.*)\}/g;
13
+
14
+ type ClientJSModuleReturn = {
15
+ esmName: string;
16
+ jsId: string;
17
+ publicPath: string;
18
+ renderScriptTag: (loadScript?: ((module: unknown) => HSHtml | string) | string) => HSHtml;
19
+ }
6
20
 
7
21
  /**
8
- * Render a client JS module as a script tag
22
+ * Load a client JS module
9
23
  */
10
- export function renderClientJS<T>(module: T, loadScript?: ((module: T) => void) | string) {
11
- // @ts-ignore
12
- if (!module.__CLIENT_JS) {
13
- throw new Error(
14
- `[Hyperspan] Client JS was not loaded by Hyperspan! Ensure the filename ends with .client.ts to use this render method.`
15
- );
24
+ export async function loadClientJS(modulePathResolved: string): Promise<ClientJSModuleReturn> {
25
+ const modulePath = modulePathResolved.replace('file://', '');
26
+ const jsId = assetHash(modulePath);
27
+
28
+ // Cache: Avoid re-processing the same file
29
+ if (!CLIENT_JS_CACHE.has(jsId)) {
30
+
31
+ // Build the client JS module
32
+ const result = await Bun.build({
33
+ entrypoints: [modulePath],
34
+ outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
35
+ naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
36
+ external: Array.from(JS_IMPORT_MAP.keys()),
37
+ minify: true,
38
+ format: 'esm',
39
+ target: 'browser',
40
+ env: 'APP_PUBLIC_*',
41
+ });
42
+
43
+ // Add output file to import map
44
+ const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
45
+ const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
46
+ JS_IMPORT_MAP.set(esmName, publicPath);
47
+
48
+ // Get the contents of the file to extract the exports
49
+ const contents = await result.outputs[0].text();
50
+ const exportLine = EXPORT_REGEX.exec(contents);
51
+
52
+ let exports = '{}';
53
+ if (exportLine) {
54
+ const exportName = exportLine[1];
55
+ exports =
56
+ '{' +
57
+ exportName
58
+ .split(',')
59
+ .map((name) => name.trim().split(' as '))
60
+ .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
61
+ .join(', ') +
62
+ '}';
63
+ }
64
+ const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
65
+ CLIENT_JS_CACHE.set(jsId, { esmName, exports, fnArgs, publicPath });
16
66
  }
17
67
 
18
- return html.raw(
19
- // @ts-ignore
20
- module.__CLIENT_JS.renderScriptTag({
21
- loadScript: loadScript
22
- ? typeof loadScript === 'string'
23
- ? loadScript
24
- : functionToString(loadScript)
25
- : undefined,
26
- })
27
- );
68
+ const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(jsId)!;
69
+
70
+ return {
71
+ esmName,
72
+ jsId,
73
+ publicPath,
74
+ renderScriptTag: (loadScript) => {
75
+ const t = typeof loadScript;
76
+
77
+ if (t === 'string') {
78
+ return html`
79
+ <script type="module" data-source-id="${jsId}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
80
+ `;
81
+ }
82
+ if (t === 'function') {
83
+ return html`
84
+ <script type="module" data-source-id="${jsId}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
85
+ `;
86
+ }
87
+
88
+ return html`
89
+ <script type="module" data-source-id="${jsId}">import "${esmName}";</script>
90
+ `;
91
+ }
92
+ }
28
93
  }
29
94
 
30
95
  /**
@@ -34,28 +99,5 @@ export function renderClientJS<T>(module: T, loadScript?: ((module: T) => void)
34
99
  export function functionToString(fn: any) {
35
100
  let str = fn.toString().trim();
36
101
 
37
- // Ensure consistent output & handle async
38
- if (!str.includes('function ')) {
39
- if (str.includes('async ')) {
40
- str = 'async function ' + str.replace('async ', '');
41
- } else {
42
- str = 'function ' + str;
43
- }
44
- }
45
-
46
- const lines = str.split('\n');
47
- const firstLine = lines[0];
48
- const lastLine = lines[lines.length - 1];
49
-
50
- // Arrow function conversion
51
- if (!lastLine?.includes('}')) {
52
- return str.replace('=> ', '{ return ') + '; }';
53
- }
54
-
55
- // Cleanup arrow function
56
- if (firstLine.includes('=>')) {
57
- return str.replace('=> ', '');
58
- }
59
-
60
102
  return str;
61
103
  }
package/src/layout.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { html } from '@hyperspan/html';
2
- import { JS_IMPORT_MAP } from './client/js';
2
+ import { JS_IMPORT_MAP, loadClientJS } from './client/js';
3
3
  import { CSS_PUBLIC_PATH, CSS_ROUTE_MAP } from './client/css';
4
4
  import type { Hyperspan as HS } from './types';
5
5
 
6
+ const clientStreamingJS = await loadClientJS(import.meta.resolve('./client/_hs/hyperspan-streaming.client'));
7
+
6
8
  /**
7
9
  * Output the importmap for the client so we can use ESModules on the client to load JS files on demand
8
10
  */
@@ -11,6 +13,27 @@ export function hyperspanScriptTags() {
11
13
  <script type="importmap">
12
14
  {"imports": ${Object.fromEntries(JS_IMPORT_MAP)}}
13
15
  </script>
16
+ <script id="hyperspan-streaming-script">
17
+ // [Hyperspan] Streaming - Load the client streaming JS module only when the first chunk is loaded
18
+ window._hsc = window._hsc || [];
19
+ var hscc = function(e) {
20
+ if (window._hscc !== undefined) {
21
+ window._hscc(e);
22
+ }
23
+ };
24
+ window._hsc.push = function(e) {
25
+ Array.prototype.push.call(window._hsc, e);
26
+ if (window._hsc.length === 1) {
27
+ const script = document.createElement('script');
28
+ script.src = "${clientStreamingJS.publicPath}";
29
+ document.body.appendChild(script);
30
+ script.onload = function() {
31
+ hscc(e);
32
+ };
33
+ }
34
+ hscc(e);
35
+ };
36
+ </script>
14
37
  `;
15
38
  }
16
39
 
package/src/server.ts CHANGED
@@ -1,11 +1,9 @@
1
- import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
1
+ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render, _typeOf } from '@hyperspan/html';
2
2
  import { executeMiddleware } from './middleware';
3
- import { clientJSPlugin } from './plugins';
4
3
  import { parsePath } from './utils';
5
4
  import { Cookies } from './cookies';
6
5
 
7
6
  import type { Hyperspan as HS } from './types';
8
- import { RequestOptions } from 'node:http';
9
7
 
10
8
  export const IS_PROD = process.env.NODE_ENV === 'production';
11
9
 
@@ -25,7 +23,7 @@ export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
25
23
  ...config,
26
24
  appDir: config.appDir ?? './app',
27
25
  publicDir: config.publicDir ?? './public',
28
- plugins: [clientJSPlugin(), ...(config.plugins ?? [])],
26
+ plugins: config.plugins ?? [],
29
27
  };
30
28
  }
31
29
 
@@ -65,6 +63,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
65
63
  return {
66
64
  vars: {},
67
65
  route: {
66
+ name: route?._config.name || undefined,
68
67
  path,
69
68
  params: params,
70
69
  cssImports: route ? route._config.cssImports ?? [] : [],
@@ -101,7 +100,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
101
100
  * Define a route that can handle a direct HTTP request.
102
101
  * Route handlers should return a HSHtml or Response object
103
102
  */
104
- export function createRoute(config: HS.RouteConfig = {}): HS.Route {
103
+ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
105
104
  const _handlers: Record<string, HS.RouteHandler> = {};
106
105
  let _middleware: Record<string, Array<HS.MiddlewareFunction>> = { '*': [] };
107
106
 
@@ -227,6 +226,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
227
226
  return returnHTMLResponse(context, () => routeContent);
228
227
  }
229
228
 
229
+ const contentType = _typeOf(routeContent);
230
+ if (contentType === 'generator') {
231
+ return new StreamResponse(routeContent as AsyncGenerator);
232
+ }
233
+
230
234
  return routeContent;
231
235
  };
232
236
 
@@ -337,13 +341,23 @@ export async function returnHTMLResponse(
337
341
  // Render HSHtml if returned from route handler
338
342
  if (isHSHtml(routeContent)) {
339
343
  // @TODO: Move this to config or something...
340
- const streamOpt = context.req.query.get('__nostream');
341
- const streamingEnabled = (streamOpt !== undefined ? streamOpt : true);
344
+ const disableStreaming = context.req.query.get('__nostream') ?? '0';
345
+ const streamingEnabled = disableStreaming !== '1';
342
346
 
343
347
  // Stream only if enabled and there is async content to stream
344
348
  if (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
345
349
  return new StreamResponse(
346
- renderStream(routeContent as HSHtml),
350
+ renderStream(routeContent as HSHtml, {
351
+ renderChunk: (chunk) => {
352
+ return html`
353
+ <template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
354
+ <script>
355
+ window._hsc = window._hsc || [];
356
+ window._hsc.push({id: "${chunk.id}" });
357
+ </script>
358
+ `;
359
+ }
360
+ }),
347
361
  responseOptions
348
362
  ) as Response;
349
363
  } else {
package/src/types.ts CHANGED
@@ -88,10 +88,10 @@ export namespace Hyperspan {
88
88
  };
89
89
 
90
90
  export type RouteConfig = {
91
- name?: string;
92
- path?: string;
93
- params?: Record<string, string | undefined>;
94
- cssImports?: string[];
91
+ name: string | undefined;
92
+ path: string;
93
+ params: Record<string, string | undefined>;
94
+ cssImports: string[];
95
95
  };
96
96
  export type RouteHandler = (context: Hyperspan.Context) => unknown;
97
97
  export type RouteHandlerOptions = {
@@ -124,7 +124,7 @@ export namespace Hyperspan {
124
124
 
125
125
  export interface Route {
126
126
  _kind: 'hsRoute';
127
- _config: Hyperspan.RouteConfig;
127
+ _config: Partial<Hyperspan.RouteConfig>;
128
128
  _path(): string;
129
129
  _methods(): string[];
130
130
  get: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
package/src/plugins.ts DELETED
@@ -1,94 +0,0 @@
1
- import type { Hyperspan as HS } from './types';
2
- import { JS_PUBLIC_PATH, JS_IMPORT_MAP } from './client/js';
3
- import { assetHash } from './utils';
4
- import { IS_PROD } from './server';
5
- import { join } from 'node:path';
6
-
7
- export const CSS_PUBLIC_PATH = '/_hs/css';
8
- const CLIENT_JS_CACHE = new Map<string, string>();
9
- const EXPORT_REGEX = /export\{(.*)\}/g;
10
-
11
- /**
12
- * Hyperspan Client JS Plugin
13
- */
14
- export function clientJSPlugin(): HS.Plugin {
15
- return async (config: HS.Config) => {
16
- // Define a Bun plugin to handle .client.ts files
17
- await Bun.plugin({
18
- name: 'Hyperspan Client JS Loader',
19
- async setup(build) {
20
- // when a .client.ts file is imported...
21
- build.onLoad({ filter: /\.client\.ts$/ }, async (args) => {
22
- const jsId = assetHash(args.path);
23
-
24
- // Cache: Avoid re-processing the same file
25
- if (IS_PROD && CLIENT_JS_CACHE.has(jsId)) {
26
- return {
27
- contents: CLIENT_JS_CACHE.get(jsId) || '',
28
- loader: 'js',
29
- };
30
- }
31
-
32
- // We need to build the file to ensure we can ship it to the client with dependencies
33
- // Ironic, right? Calling Bun.build() inside of a plugin that runs on Bun.build()?
34
- const result = await Bun.build({
35
- entrypoints: [args.path],
36
- outdir: join(config.publicDir, JS_PUBLIC_PATH),
37
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
38
- external: Array.from(JS_IMPORT_MAP.keys()),
39
- minify: IS_PROD,
40
- format: 'esm',
41
- target: 'browser',
42
- env: 'APP_PUBLIC_*',
43
- });
44
-
45
- // Add output file to import map
46
- const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
47
- JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
48
-
49
- // Get the contents of the file to extract the exports
50
- const contents = await result.outputs[0].text();
51
- const exportLine = EXPORT_REGEX.exec(contents);
52
-
53
- let exports = '{}';
54
- if (exportLine) {
55
- const exportName = exportLine[1];
56
- exports =
57
- '{' +
58
- exportName
59
- .split(',')
60
- .map((name) => name.trim().split(' as '))
61
- .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
62
- .join(', ') +
63
- '}';
64
- }
65
- const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
66
-
67
- // Export a special object that can be used to render the client JS as a script tag
68
- const moduleCode = `// hyperspan:processed
69
- import { functionToString } from '@hyperspan/framework/client/js';
70
-
71
- // hyperspan:client-js-plugin
72
- export const __CLIENT_JS = {
73
- id: "${jsId}",
74
- esmName: "${esmName}",
75
- sourceFile: "${args.path}",
76
- outputFile: "${result.outputs[0].path}",
77
- renderScriptTag: ({ loadScript }) => {
78
- const fn = loadScript ? (typeof loadScript === 'string' ? loadScript : \`const fn = \${functionToString(loadScript)}; fn(${fnArgs});\`) : '';
79
- return \`<script type="module" data-source-id="${jsId}">import ${exports} from "${esmName}";\n\${fn}</script>\`;
80
- },
81
- }
82
- `;
83
-
84
- CLIENT_JS_CACHE.set(jsId, moduleCode);
85
-
86
- return {
87
- contents: moduleCode,
88
- loader: 'js',
89
- };
90
- });
91
- },
92
- });
93
- };
94
- }