@hyperspan/framework 1.0.0-alpha.13 → 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 +1 -5
- package/src/client/_hs/hyperspan-streaming.client.ts +36 -64
- package/src/client/js.test.ts +200 -0
- package/src/client/js.ts +83 -41
- package/src/layout.ts +24 -1
- package/src/server.ts +20 -7
- package/src/plugins.ts +0 -94
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
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
|
-
*
|
|
22
|
+
* Load a client JS module
|
|
9
23
|
*/
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
26
|
+
plugins: config.plugins ?? [],
|
|
29
27
|
};
|
|
30
28
|
}
|
|
31
29
|
|
|
@@ -228,6 +226,11 @@ export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
|
228
226
|
return returnHTMLResponse(context, () => routeContent);
|
|
229
227
|
}
|
|
230
228
|
|
|
229
|
+
const contentType = _typeOf(routeContent);
|
|
230
|
+
if (contentType === 'generator') {
|
|
231
|
+
return new StreamResponse(routeContent as AsyncGenerator);
|
|
232
|
+
}
|
|
233
|
+
|
|
231
234
|
return routeContent;
|
|
232
235
|
};
|
|
233
236
|
|
|
@@ -338,13 +341,23 @@ export async function returnHTMLResponse(
|
|
|
338
341
|
// Render HSHtml if returned from route handler
|
|
339
342
|
if (isHSHtml(routeContent)) {
|
|
340
343
|
// @TODO: Move this to config or something...
|
|
341
|
-
const
|
|
342
|
-
const streamingEnabled =
|
|
344
|
+
const disableStreaming = context.req.query.get('__nostream') ?? '0';
|
|
345
|
+
const streamingEnabled = disableStreaming !== '1';
|
|
343
346
|
|
|
344
347
|
// Stream only if enabled and there is async content to stream
|
|
345
348
|
if (streamingEnabled && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
346
349
|
return new StreamResponse(
|
|
347
|
-
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
|
+
}),
|
|
348
361
|
responseOptions
|
|
349
362
|
) as Response;
|
|
350
363
|
} else {
|
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
|
-
}
|