@hyperspan/framework 1.0.0-alpha.9 → 1.0.0
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 +3 -6
- package/src/actions.ts +12 -7
- package/src/client/_hs/hyperspan-streaming.client.ts +36 -64
- package/src/client/js.test.ts +200 -0
- package/src/client/js.ts +76 -43
- package/src/cookies.ts +1 -1
- package/src/index.ts +1 -1
- package/src/layout.ts +24 -1
- package/src/middleware.ts +87 -1
- package/src/server.test.ts +2 -8
- package/src/server.ts +81 -37
- package/src/types.ts +63 -35
- package/src/utils.test.ts +2 -2
- package/src/utils.ts +1 -1
- 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
|
|
3
|
+
"version": "1.0.0",
|
|
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"
|
|
@@ -76,7 +72,8 @@
|
|
|
76
72
|
"typescript": "^5.9.3"
|
|
77
73
|
},
|
|
78
74
|
"dependencies": {
|
|
79
|
-
"@hyperspan/html": "^1.0.0
|
|
75
|
+
"@hyperspan/html": "^1.0.0",
|
|
76
|
+
"isbot": "^5.1.32",
|
|
80
77
|
"zod": "^4.1.12"
|
|
81
78
|
}
|
|
82
79
|
}
|
package/src/actions.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { html
|
|
1
|
+
import { html } from '@hyperspan/html';
|
|
2
2
|
import { createRoute, returnHTMLResponse } from './server';
|
|
3
3
|
import * as z from 'zod/v4';
|
|
4
4
|
import type { Hyperspan as HS } from './types';
|
|
5
5
|
import { assetHash, formDataToJSON } from './utils';
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
6
|
+
import { buildClientJS } from './client/js';
|
|
7
|
+
import { validateBody } from './middleware';
|
|
8
|
+
|
|
9
|
+
const actionsClientJS = await buildClientJS(import.meta.resolve('./client/_hs/hyperspan-actions.client'));
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Actions = Form + route handler
|
|
@@ -19,7 +21,7 @@ import { renderClientJS } from './client/js';
|
|
|
19
21
|
* 5. Replaces form content in place with HTML response content from server via the Idiomorph library
|
|
20
22
|
* 6. Handles any Exception thrown on server as error displayed back to user on the page
|
|
21
23
|
*/
|
|
22
|
-
export function createAction<T extends z.
|
|
24
|
+
export function createAction<T extends z.ZodObject<any, any>>(params: { name: string; schema?: T }): HS.Action<T> {
|
|
23
25
|
const { name, schema } = params;
|
|
24
26
|
const path = `/__actions/${assetHash(name)}`;
|
|
25
27
|
|
|
@@ -30,7 +32,7 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
|
|
|
30
32
|
.get((c: HS.Context) => api.render(c))
|
|
31
33
|
.post(async (c: HS.Context) => {
|
|
32
34
|
// Parse form data
|
|
33
|
-
const formData = await c.req.
|
|
35
|
+
const formData = await c.req.formData();
|
|
34
36
|
const jsonData = formDataToJSON(formData) as Partial<z.infer<T>>;
|
|
35
37
|
const schemaData = schema ? schema.safeParse(jsonData) : null;
|
|
36
38
|
const data = schemaData?.success ? (schemaData.data as Partial<z.infer<T>>) : jsonData;
|
|
@@ -69,7 +71,10 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
|
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
|
|
72
|
-
});
|
|
74
|
+
}, { middleware: schema ? [validateBody(schema)] : [] });
|
|
75
|
+
|
|
76
|
+
// Set the name of the action for the route
|
|
77
|
+
route._config.name = name;
|
|
73
78
|
|
|
74
79
|
const api: HS.Action<T> = {
|
|
75
80
|
_kind: 'hsAction',
|
|
@@ -101,7 +106,7 @@ export function createAction<T extends z.ZodTypeAny>(params: { name: string; sch
|
|
|
101
106
|
*/
|
|
102
107
|
render(c: HS.Context, props?: HS.ActionProps<T>) {
|
|
103
108
|
const formContent = api._form ? api._form(c, props || {}) : null;
|
|
104
|
-
return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${
|
|
109
|
+
return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${actionsClientJS.renderScriptTag()}` : null;
|
|
105
110
|
},
|
|
106
111
|
errorHandler(handler) {
|
|
107
112
|
_errorHandler = handler;
|
|
@@ -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,61 +1,94 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
|
+
import { assetHash as assetHashFn } from '../utils';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { Hyperspan as HS } from '../types';
|
|
5
|
+
|
|
6
|
+
const CWD = process.cwd();
|
|
7
|
+
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
2
8
|
|
|
3
9
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
4
10
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
5
11
|
export const JS_IMPORT_MAP = new Map<string, string>();
|
|
12
|
+
const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
|
|
13
|
+
const EXPORT_REGEX = /export\{(.*)\}/g;
|
|
6
14
|
|
|
7
15
|
/**
|
|
8
|
-
*
|
|
16
|
+
* Build a client JS module and return a Hyperspan.ClientJSBuildResult object
|
|
9
17
|
*/
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
);
|
|
16
|
-
}
|
|
18
|
+
export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
|
|
19
|
+
const modulePath = modulePathResolved.replace('file://', '');
|
|
20
|
+
const assetHash = assetHashFn(modulePath);
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
module
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
// Cache: Avoid re-processing the same file
|
|
23
|
+
if (!CLIENT_JS_CACHE.has(assetHash)) {
|
|
24
|
+
// Build the client JS module
|
|
25
|
+
const result = await Bun.build({
|
|
26
|
+
entrypoints: [modulePath],
|
|
27
|
+
outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
|
|
28
|
+
naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
|
|
29
|
+
external: Array.from(JS_IMPORT_MAP.keys()),
|
|
30
|
+
minify: true,
|
|
31
|
+
format: 'esm',
|
|
32
|
+
target: 'browser',
|
|
33
|
+
env: 'APP_PUBLIC_*',
|
|
34
|
+
});
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
// Add output file to import map
|
|
37
|
+
const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
38
|
+
const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
|
|
39
|
+
JS_IMPORT_MAP.set(esmName, publicPath);
|
|
40
|
+
|
|
41
|
+
// Get the contents of the file to extract the exports
|
|
42
|
+
const contents = await result.outputs[0].text();
|
|
43
|
+
const exportLine = EXPORT_REGEX.exec(contents);
|
|
44
|
+
|
|
45
|
+
let exports = '{}';
|
|
46
|
+
if (exportLine) {
|
|
47
|
+
const exportName = exportLine[1];
|
|
48
|
+
exports =
|
|
49
|
+
'{' +
|
|
50
|
+
exportName
|
|
51
|
+
.split(',')
|
|
52
|
+
.map((name) => name.trim().split(' as '))
|
|
53
|
+
.map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
|
|
54
|
+
.join(', ') +
|
|
55
|
+
'}';
|
|
43
56
|
}
|
|
57
|
+
const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
|
|
58
|
+
CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
|
|
44
59
|
}
|
|
45
60
|
|
|
46
|
-
const
|
|
47
|
-
const firstLine = lines[0];
|
|
48
|
-
const lastLine = lines[lines.length - 1];
|
|
61
|
+
const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
return {
|
|
64
|
+
assetHash,
|
|
65
|
+
esmName,
|
|
66
|
+
publicPath,
|
|
67
|
+
renderScriptTag: (loadScript) => {
|
|
68
|
+
const t = typeof loadScript;
|
|
69
|
+
|
|
70
|
+
if (t === 'string') {
|
|
71
|
+
return html`
|
|
72
|
+
<script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
if (t === 'function') {
|
|
76
|
+
return html`
|
|
77
|
+
<script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
54
80
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
return html`
|
|
82
|
+
<script type="module" data-source-id="${assetHash}">import "${esmName}";</script>
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
58
85
|
}
|
|
86
|
+
}
|
|
59
87
|
|
|
60
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Convert a function to a string (results in loss of context!)
|
|
90
|
+
* Handles named, async, and arrow functions
|
|
91
|
+
*/
|
|
92
|
+
export function functionToString(fn: any) {
|
|
93
|
+
return fn.toString().trim();
|
|
61
94
|
}
|
package/src/cookies.ts
CHANGED
|
@@ -42,7 +42,7 @@ export class Cookies implements HS.Cookies {
|
|
|
42
42
|
if (this._encrypt) {
|
|
43
43
|
value = this._encrypt(value);
|
|
44
44
|
}
|
|
45
|
-
this._responseHeaders.
|
|
45
|
+
this._responseHeaders.append('Set-Cookie', serialize(name, value, options));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
delete(name: string) {
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD,
|
|
1
|
+
export { createConfig, createContext, createRoute, createServer, getRunnableRoute, StreamResponse, IS_PROD, HTTPResponseException } from './server';
|
|
2
2
|
export type { Hyperspan } from './types';
|
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, buildClientJS } 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 buildClientJS(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/middleware.ts
CHANGED
|
@@ -1,4 +1,91 @@
|
|
|
1
|
+
import { formDataToJSON } from './utils';
|
|
2
|
+
import { z, flattenError } from 'zod/v4';
|
|
3
|
+
|
|
4
|
+
import type { ZodAny, ZodObject, ZodError } from 'zod/v4';
|
|
1
5
|
import type { Hyperspan as HS } from './types';
|
|
6
|
+
import { HTTPResponseException } from './server';
|
|
7
|
+
|
|
8
|
+
export type TValidationType = 'json' | 'form' | 'urlencoded';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Infer the validation type from the request Content-Type header
|
|
12
|
+
*/
|
|
13
|
+
function inferValidationType(headers: Headers): TValidationType {
|
|
14
|
+
const contentType = headers.get('content-type')?.toLowerCase() || '';
|
|
15
|
+
|
|
16
|
+
if (contentType.includes('application/json')) {
|
|
17
|
+
return 'json';
|
|
18
|
+
} else if (contentType.includes('multipart/form-data')) {
|
|
19
|
+
return 'form';
|
|
20
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
21
|
+
return 'urlencoded';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Default to json if content-type is not recognized
|
|
25
|
+
return 'json';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ZodValidationError extends Error {
|
|
29
|
+
constructor(flattened: ReturnType<typeof flattenError>) {
|
|
30
|
+
super('Input validation error(s)');
|
|
31
|
+
this.name = 'ZodValidationError';
|
|
32
|
+
|
|
33
|
+
// Copy all properties from flattened error
|
|
34
|
+
Object.assign(this, flattened);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateQuery(schema: ZodObject | ZodAny): HS.MiddlewareFunction {
|
|
39
|
+
return async (context: HS.Context, next: HS.NextFunction) => {
|
|
40
|
+
const query = formDataToJSON(context.req.query);
|
|
41
|
+
const validated = schema.safeParse(query);
|
|
42
|
+
|
|
43
|
+
if (!validated.success) {
|
|
44
|
+
const err = formatZodError(validated.error);
|
|
45
|
+
return context.res.error(err, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Store the validated query in the context variables
|
|
49
|
+
context.vars.query = validated.data as z.infer<typeof schema>;
|
|
50
|
+
|
|
51
|
+
return next();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateBody(schema: ZodObject | ZodAny, type?: TValidationType): HS.MiddlewareFunction {
|
|
56
|
+
return async (context: HS.Context, next: HS.NextFunction) => {
|
|
57
|
+
// Infer type from Content-Type header if not provided
|
|
58
|
+
const validationType = type || inferValidationType(context.req.headers);
|
|
59
|
+
|
|
60
|
+
let body: unknown = {};
|
|
61
|
+
if (validationType === 'json') {
|
|
62
|
+
body = await context.req.raw.json();
|
|
63
|
+
} else if (validationType === 'form') {
|
|
64
|
+
const formData = await context.req.formData();
|
|
65
|
+
body = formDataToJSON(formData as FormData);
|
|
66
|
+
} else if (validationType === 'urlencoded') {
|
|
67
|
+
const urlencoded = await context.req.urlencoded();
|
|
68
|
+
body = formDataToJSON(urlencoded);
|
|
69
|
+
}
|
|
70
|
+
const validated = schema.safeParse(body);
|
|
71
|
+
|
|
72
|
+
if (!validated.success) {
|
|
73
|
+
const err = formatZodError(validated.error);
|
|
74
|
+
throw new HTTPResponseException(err, { status: 400 });
|
|
75
|
+
//return context.res.error(err, { status: 400 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Store the validated body in the context variables
|
|
79
|
+
context.vars.body = validated.data as z.infer<typeof schema>;
|
|
80
|
+
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function formatZodError(error: ZodError): ZodValidationError {
|
|
86
|
+
const zodError = flattenError(error);
|
|
87
|
+
return new ZodValidationError(zodError);
|
|
88
|
+
}
|
|
2
89
|
|
|
3
90
|
/**
|
|
4
91
|
* Type guard to check if a handler is a middleware function
|
|
@@ -55,4 +142,3 @@ export async function executeMiddleware(
|
|
|
55
142
|
// Start execution from the first handler
|
|
56
143
|
return await createNext(0)();
|
|
57
144
|
}
|
|
58
|
-
|
package/src/server.test.ts
CHANGED
|
@@ -113,7 +113,7 @@ test('createContext() can get and set cookies', () => {
|
|
|
113
113
|
expect(setCookieHeader).toBeTruthy();
|
|
114
114
|
expect(setCookieHeader).toContain('newCookie=newValue');
|
|
115
115
|
|
|
116
|
-
// Test setting a cookie with options (this
|
|
116
|
+
// Test setting a cookie with options (this should NOT overwrite the previous Set-Cookie header)
|
|
117
117
|
context.res.cookies.set('secureCookie', 'secureValue', {
|
|
118
118
|
httpOnly: true,
|
|
119
119
|
secure: true,
|
|
@@ -125,13 +125,7 @@ test('createContext() can get and set cookies', () => {
|
|
|
125
125
|
setCookieHeader = context.res.headers.get('Set-Cookie');
|
|
126
126
|
expect(setCookieHeader).toBeTruthy();
|
|
127
127
|
expect(setCookieHeader).toContain('secureCookie=secureValue');
|
|
128
|
-
expect(setCookieHeader).toContain('
|
|
129
|
-
expect(setCookieHeader).toContain('Secure');
|
|
130
|
-
expect(setCookieHeader).toContain('SameSite=Strict');
|
|
131
|
-
expect(setCookieHeader).toContain('Max-Age=3600');
|
|
132
|
-
|
|
133
|
-
// Verify the previous cookie was overwritten
|
|
134
|
-
expect(setCookieHeader).not.toContain('newCookie=newValue');
|
|
128
|
+
expect(setCookieHeader).toContain('newCookie=newValue');
|
|
135
129
|
|
|
136
130
|
// Test deleting a cookie
|
|
137
131
|
context.res.cookies.delete('sessionId');
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hyperspan/html';
|
|
1
|
+
import { HSHtml, html, isHSHtml, renderStream, renderAsync, render, _typeOf } from '@hyperspan/html';
|
|
2
|
+
import { isbot } from 'isbot';
|
|
2
3
|
import { executeMiddleware } from './middleware';
|
|
3
|
-
import { clientJSPlugin } from './plugins';
|
|
4
4
|
import { parsePath } from './utils';
|
|
5
5
|
import { Cookies } from './cookies';
|
|
6
6
|
|
|
@@ -8,9 +8,13 @@ import type { Hyperspan as HS } from './types';
|
|
|
8
8
|
|
|
9
9
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
10
10
|
|
|
11
|
-
export class
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export class HTTPResponseException extends Error {
|
|
12
|
+
public _error?: Error;
|
|
13
|
+
public _response?: Response;
|
|
14
|
+
constructor(body: string | Error | undefined, options?: ResponseInit) {
|
|
15
|
+
super(body instanceof Error ? body.message : body);
|
|
16
|
+
this._error = body instanceof Error ? body : undefined;
|
|
17
|
+
this._response = new Response(body instanceof Error ? body.message : body, options);
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -18,11 +22,22 @@ export class HTTPException extends Error {
|
|
|
18
22
|
* Ensures a valid config object is returned, even with an empty object or partial object passed in
|
|
19
23
|
*/
|
|
20
24
|
export function createConfig(config: Partial<HS.Config> = {}): HS.Config {
|
|
25
|
+
const defaultConfig: HS.Config = {
|
|
26
|
+
appDir: './app',
|
|
27
|
+
publicDir: './public',
|
|
28
|
+
plugins: [],
|
|
29
|
+
responseOptions: {
|
|
30
|
+
// Disable streaming for bots by default
|
|
31
|
+
disableStreaming: (c) => isbot(c.req.raw.headers.get('user-agent') ?? ''),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
21
34
|
return {
|
|
35
|
+
...defaultConfig,
|
|
22
36
|
...config,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
responseOptions: {
|
|
38
|
+
...defaultConfig.responseOptions,
|
|
39
|
+
...config.responseOptions,
|
|
40
|
+
},
|
|
26
41
|
};
|
|
27
42
|
}
|
|
28
43
|
|
|
@@ -36,7 +51,15 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
36
51
|
const headers = new Headers(req.headers);
|
|
37
52
|
const path = route?._path() || '/';
|
|
38
53
|
// @ts-ignore - Bun will put 'params' on the Request object even though it's not standardized
|
|
39
|
-
const params: HS.RouteParamsParser<path> = req?.params || {};
|
|
54
|
+
const params: HS.RouteParamsParser<path> & Record<string, string | undefined> = Object.assign({}, req?.params || {}, route?._config.params || {});
|
|
55
|
+
|
|
56
|
+
// Replace catch-all param with the value from the URL path
|
|
57
|
+
const catchAllParam = Object.keys(params).find(key => key.startsWith('...'));
|
|
58
|
+
if (catchAllParam && path.includes('/*')) {
|
|
59
|
+
const catchAllValue = url.pathname.split(path.replace('/*', '/')).pop();
|
|
60
|
+
params[catchAllParam.replace('...', '')] = catchAllValue;
|
|
61
|
+
delete params[catchAllParam];
|
|
62
|
+
}
|
|
40
63
|
|
|
41
64
|
const merge = (response: Response) => {
|
|
42
65
|
// Convert headers to plain objects and merge (response headers override context headers)
|
|
@@ -54,6 +77,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
54
77
|
return {
|
|
55
78
|
vars: {},
|
|
56
79
|
route: {
|
|
80
|
+
name: route?._config.name || undefined,
|
|
57
81
|
path,
|
|
58
82
|
params: params,
|
|
59
83
|
cssImports: route ? route._config.cssImports ?? [] : [],
|
|
@@ -74,12 +98,12 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
74
98
|
cookies: new Cookies(req, headers),
|
|
75
99
|
headers,
|
|
76
100
|
raw: new Response(),
|
|
77
|
-
html: (html: string, options?:
|
|
78
|
-
json: (json: any, options?:
|
|
79
|
-
text: (text: string, options?:
|
|
80
|
-
redirect: (url: string, options?:
|
|
81
|
-
error: (error: Error, options?:
|
|
82
|
-
notFound: (options?:
|
|
101
|
+
html: (html: string, options?: ResponseInit) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
|
|
102
|
+
json: (json: any, options?: ResponseInit) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
|
|
103
|
+
text: (text: string, options?: ResponseInit) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
|
|
104
|
+
redirect: (url: string, options?: ResponseInit) => merge(new Response(null, { status: 302, headers: { Location: url, ...options?.headers } })),
|
|
105
|
+
error: (error: Error, options?: ResponseInit) => merge(new Response(error.message, { status: 500, ...options })),
|
|
106
|
+
notFound: (options?: ResponseInit) => merge(new Response('Not Found', { status: 404, ...options })),
|
|
83
107
|
merge,
|
|
84
108
|
},
|
|
85
109
|
};
|
|
@@ -90,8 +114,9 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
90
114
|
* Define a route that can handle a direct HTTP request.
|
|
91
115
|
* Route handlers should return a HSHtml or Response object
|
|
92
116
|
*/
|
|
93
|
-
export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
117
|
+
export function createRoute(config: Partial<HS.RouteConfig> = {}): HS.Route {
|
|
94
118
|
const _handlers: Record<string, HS.RouteHandler> = {};
|
|
119
|
+
let _errorHandler: HS.ErrorHandler | undefined = undefined;
|
|
95
120
|
let _middleware: Record<string, Array<HS.MiddlewareFunction>> = { '*': [] };
|
|
96
121
|
|
|
97
122
|
const api: HS.Route = {
|
|
@@ -130,14 +155,6 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
130
155
|
_middleware['PUT'] = handlerOptions?.middleware || [];
|
|
131
156
|
return api;
|
|
132
157
|
},
|
|
133
|
-
/**
|
|
134
|
-
* Add a DELETE route handler (typically to delete existing data)
|
|
135
|
-
*/
|
|
136
|
-
delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
137
|
-
_handlers['DELETE'] = handler;
|
|
138
|
-
_middleware['DELETE'] = handlerOptions?.middleware || [];
|
|
139
|
-
return api;
|
|
140
|
-
},
|
|
141
158
|
/**
|
|
142
159
|
* Add a PATCH route handler (typically to update existing data)
|
|
143
160
|
*/
|
|
@@ -146,6 +163,14 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
146
163
|
_middleware['PATCH'] = handlerOptions?.middleware || [];
|
|
147
164
|
return api;
|
|
148
165
|
},
|
|
166
|
+
/**
|
|
167
|
+
* Add a DELETE route handler (typically to delete existing data)
|
|
168
|
+
*/
|
|
169
|
+
delete(handler: HS.RouteHandler, handlerOptions?: HS.RouteHandlerOptions) {
|
|
170
|
+
_handlers['DELETE'] = handler;
|
|
171
|
+
_middleware['DELETE'] = handlerOptions?.middleware || [];
|
|
172
|
+
return api;
|
|
173
|
+
},
|
|
149
174
|
/**
|
|
150
175
|
* Add a OPTIONS route handler (typically to handle CORS preflight requests)
|
|
151
176
|
*/
|
|
@@ -154,8 +179,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
154
179
|
_middleware['OPTIONS'] = handlerOptions?.middleware || [];
|
|
155
180
|
return api;
|
|
156
181
|
},
|
|
157
|
-
|
|
158
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Set a custom error handler for this route to fall back to if the route handler throws an error
|
|
184
|
+
*/
|
|
185
|
+
errorHandler(handler: HS.ErrorHandler) {
|
|
186
|
+
_errorHandler = handler;
|
|
159
187
|
return api;
|
|
160
188
|
},
|
|
161
189
|
/**
|
|
@@ -213,7 +241,14 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
213
241
|
}
|
|
214
242
|
|
|
215
243
|
if (isHTMLContent(routeContent)) {
|
|
216
|
-
|
|
244
|
+
// Merge server and route-specific response options
|
|
245
|
+
const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
|
|
246
|
+
return returnHTMLResponse(context, () => routeContent, responseOptions);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const contentType = _typeOf(routeContent);
|
|
250
|
+
if (contentType === 'generator') {
|
|
251
|
+
return new StreamResponse(routeContent as AsyncGenerator);
|
|
217
252
|
}
|
|
218
253
|
|
|
219
254
|
return routeContent;
|
|
@@ -224,8 +259,9 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
224
259
|
try {
|
|
225
260
|
return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
|
|
226
261
|
} catch (e) {
|
|
227
|
-
if (
|
|
228
|
-
|
|
262
|
+
if (_errorHandler !== undefined) {
|
|
263
|
+
const responseOptions = { ...(api._serverConfig?.responseOptions ?? {}), ...(api._config?.responseOptions ?? {}) };
|
|
264
|
+
return returnHTMLResponse(context, () => (_errorHandler as HS.ErrorHandler)(context, e as Error), responseOptions);
|
|
229
265
|
}
|
|
230
266
|
throw e;
|
|
231
267
|
}
|
|
@@ -313,7 +349,7 @@ function isHTMLContent(response: unknown): response is Response {
|
|
|
313
349
|
export async function returnHTMLResponse(
|
|
314
350
|
context: HS.Context,
|
|
315
351
|
handlerFn: () => unknown,
|
|
316
|
-
responseOptions?: { status?: number; headers?: Record<string, string
|
|
352
|
+
responseOptions?: { status?: number; headers?: Record<string, string>; disableStreaming?: (context: HS.Context) => boolean }
|
|
317
353
|
): Promise<Response> {
|
|
318
354
|
try {
|
|
319
355
|
const routeContent = await handlerFn();
|
|
@@ -325,14 +361,22 @@ export async function returnHTMLResponse(
|
|
|
325
361
|
|
|
326
362
|
// Render HSHtml if returned from route handler
|
|
327
363
|
if (isHSHtml(routeContent)) {
|
|
328
|
-
|
|
329
|
-
const streamOpt = context.req.query.get('__nostream');
|
|
330
|
-
const streamingEnabled = (streamOpt !== undefined ? streamOpt : true);
|
|
364
|
+
const disableStreaming = responseOptions?.disableStreaming?.(context) ?? false;
|
|
331
365
|
|
|
332
366
|
// Stream only if enabled and there is async content to stream
|
|
333
|
-
if (
|
|
367
|
+
if (!disableStreaming && (routeContent as HSHtml).asyncContent?.length > 0) {
|
|
334
368
|
return new StreamResponse(
|
|
335
|
-
renderStream(routeContent as HSHtml
|
|
369
|
+
renderStream(routeContent as HSHtml, {
|
|
370
|
+
renderChunk: (chunk) => {
|
|
371
|
+
return html`
|
|
372
|
+
<template id="${chunk.id}_content">${html.raw(chunk.content)}<!--end--></template>
|
|
373
|
+
<script>
|
|
374
|
+
window._hsc = window._hsc || [];
|
|
375
|
+
window._hsc.push({id: "${chunk.id}" });
|
|
376
|
+
</script>
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
}),
|
|
336
380
|
responseOptions
|
|
337
381
|
) as Response;
|
|
338
382
|
} else {
|
|
@@ -398,8 +442,8 @@ async function showErrorReponse(
|
|
|
398
442
|
const message = err.message || 'Internal Server Error';
|
|
399
443
|
|
|
400
444
|
// Send correct status code if HTTPException
|
|
401
|
-
if (err instanceof
|
|
402
|
-
status = err.status;
|
|
445
|
+
if (err instanceof HTTPResponseException) {
|
|
446
|
+
status = err._response?.status ?? 500;
|
|
403
447
|
}
|
|
404
448
|
|
|
405
449
|
const stack = !IS_PROD && err.stack ? err.stack.split('\n').slice(1).join('\n') : '';
|
package/src/types.ts
CHANGED
|
@@ -27,6 +27,9 @@ export namespace Hyperspan {
|
|
|
27
27
|
// For customizing the routes and adding your own...
|
|
28
28
|
beforeRoutesAdded?: (server: Hyperspan.Server) => void;
|
|
29
29
|
afterRoutesAdded?: (server: Hyperspan.Server) => void;
|
|
30
|
+
responseOptions?: {
|
|
31
|
+
disableStreaming?: (context: Hyperspan.Context) => boolean;
|
|
32
|
+
};
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
export type CookieOptions = {
|
|
@@ -49,37 +52,37 @@ export namespace Hyperspan {
|
|
|
49
52
|
delete: (name: string) => void;
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
export type HSRequest = {
|
|
56
|
+
url: URL;
|
|
57
|
+
raw: Request;
|
|
58
|
+
method: string; // Always uppercase
|
|
59
|
+
headers: Headers; // Case-insensitive
|
|
60
|
+
query: URLSearchParams;
|
|
61
|
+
cookies: Hyperspan.Cookies;
|
|
62
|
+
text: () => Promise<string>;
|
|
63
|
+
json<T = unknown>(): Promise<T>;
|
|
64
|
+
formData(): Promise<FormData>;
|
|
65
|
+
urlencoded(): Promise<URLSearchParams>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type HSResponse = {
|
|
69
|
+
cookies: Hyperspan.Cookies;
|
|
70
|
+
headers: Headers; // Headers to merge with final outgoing response
|
|
71
|
+
html: (html: string, options?: ResponseInit) => Response
|
|
72
|
+
json: (json: any, options?: ResponseInit) => Response;
|
|
73
|
+
text: (text: string, options?: ResponseInit) => Response;
|
|
74
|
+
redirect: (url: string, options?: ResponseInit) => Response;
|
|
75
|
+
error: (error: Error, options?: ResponseInit) => Response;
|
|
76
|
+
notFound: (options?: ResponseInit) => Response;
|
|
77
|
+
merge: (response: Response) => Response;
|
|
78
|
+
raw: Response;
|
|
79
|
+
};
|
|
80
|
+
|
|
52
81
|
export interface Context {
|
|
53
82
|
vars: Record<string, any>;
|
|
54
|
-
route:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
cssImports?: string[];
|
|
58
|
-
}
|
|
59
|
-
req: {
|
|
60
|
-
url: URL;
|
|
61
|
-
raw: Request;
|
|
62
|
-
method: string; // Always uppercase
|
|
63
|
-
headers: Headers; // Case-insensitive
|
|
64
|
-
query: URLSearchParams;
|
|
65
|
-
cookies: Hyperspan.Cookies;
|
|
66
|
-
text: () => Promise<string>;
|
|
67
|
-
json<T = unknown>(): Promise<T>;
|
|
68
|
-
formData(): Promise<FormData>;
|
|
69
|
-
urlencoded(): Promise<URLSearchParams>;
|
|
70
|
-
};
|
|
71
|
-
res: {
|
|
72
|
-
cookies: Hyperspan.Cookies;
|
|
73
|
-
headers: Headers; // Headers to merge with final outgoing response
|
|
74
|
-
html: (html: string, options?: { status?: number; headers?: Record<string, string> }) => Response
|
|
75
|
-
json: (json: any, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
76
|
-
text: (text: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
77
|
-
redirect: (url: string, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
78
|
-
error: (error: Error, options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
79
|
-
notFound: (options?: { status?: number; headers?: Record<string, string> }) => Response;
|
|
80
|
-
merge: (response: Response) => Response;
|
|
81
|
-
raw: Response;
|
|
82
|
-
};
|
|
83
|
+
route: RouteConfig;
|
|
84
|
+
req: HSRequest;
|
|
85
|
+
res: HSResponse;
|
|
83
86
|
};
|
|
84
87
|
|
|
85
88
|
export type ClientIslandOptions = {
|
|
@@ -88,9 +91,13 @@ export namespace Hyperspan {
|
|
|
88
91
|
};
|
|
89
92
|
|
|
90
93
|
export type RouteConfig = {
|
|
91
|
-
name
|
|
92
|
-
path
|
|
93
|
-
|
|
94
|
+
name: string | undefined;
|
|
95
|
+
path: string;
|
|
96
|
+
params: Record<string, string | undefined>;
|
|
97
|
+
cssImports: string[];
|
|
98
|
+
responseOptions?: {
|
|
99
|
+
disableStreaming?: (context: Hyperspan.Context) => boolean;
|
|
100
|
+
};
|
|
94
101
|
};
|
|
95
102
|
export type RouteHandler = (context: Hyperspan.Context) => unknown;
|
|
96
103
|
export type RouteHandlerOptions = {
|
|
@@ -112,6 +119,11 @@ export namespace Hyperspan {
|
|
|
112
119
|
*/
|
|
113
120
|
export type NextFunction = () => Promise<Response>;
|
|
114
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Error handler function signature
|
|
124
|
+
*/
|
|
125
|
+
export type ErrorHandler = (context: Hyperspan.Context, error: Error) => unknown | undefined;
|
|
126
|
+
|
|
115
127
|
/**
|
|
116
128
|
* Middleware function signature
|
|
117
129
|
* Accepts context and next function, returns a Response
|
|
@@ -123,7 +135,8 @@ export namespace Hyperspan {
|
|
|
123
135
|
|
|
124
136
|
export interface Route {
|
|
125
137
|
_kind: 'hsRoute';
|
|
126
|
-
_config: Hyperspan.RouteConfig
|
|
138
|
+
_config: Partial<Hyperspan.RouteConfig>;
|
|
139
|
+
_serverConfig?: Hyperspan.Config;
|
|
127
140
|
_path(): string;
|
|
128
141
|
_methods(): string[];
|
|
129
142
|
get: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
@@ -132,7 +145,7 @@ export namespace Hyperspan {
|
|
|
132
145
|
patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
133
146
|
delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
134
147
|
options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
135
|
-
errorHandler: (handler: Hyperspan.
|
|
148
|
+
errorHandler: (handler: Hyperspan.ErrorHandler) => Hyperspan.Route;
|
|
136
149
|
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
|
|
137
150
|
fetch: (request: Request) => Promise<Response>;
|
|
138
151
|
};
|
|
@@ -147,7 +160,7 @@ export namespace Hyperspan {
|
|
|
147
160
|
) => ActionResponse;
|
|
148
161
|
export interface Action<T extends z.ZodTypeAny> {
|
|
149
162
|
_kind: 'hsAction';
|
|
150
|
-
_config: Hyperspan.RouteConfig
|
|
163
|
+
_config: Partial<Hyperspan.RouteConfig>;
|
|
151
164
|
_path(): string;
|
|
152
165
|
_form: null | ActionFormHandler<T>;
|
|
153
166
|
form(form: ActionFormHandler<T>): Action<T>;
|
|
@@ -157,4 +170,19 @@ export namespace Hyperspan {
|
|
|
157
170
|
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
|
|
158
171
|
fetch: (request: Request) => Promise<Response>;
|
|
159
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Client JS Module = ESM Module + Public Path + Render Script Tag
|
|
176
|
+
*/
|
|
177
|
+
export type ClientJSBuildResult = {
|
|
178
|
+
assetHash: string; // Asset hash of the module path
|
|
179
|
+
esmName: string; // Filename of the built JavaScript file without the extension
|
|
180
|
+
publicPath: string; // Full public path of the built JavaScript file
|
|
181
|
+
/**
|
|
182
|
+
* Render a <script type="module"> tag for the JS module
|
|
183
|
+
* @param loadScript - A function that loads the module or a string of code to load the module
|
|
184
|
+
* @returns HSHtml Template with the <script type="module"> tag
|
|
185
|
+
*/
|
|
186
|
+
renderScriptTag: (loadScript?: ((module: unknown) => HSHtml | string) | string) => HSHtml;
|
|
187
|
+
}
|
|
160
188
|
}
|
package/src/utils.test.ts
CHANGED
|
@@ -149,13 +149,13 @@ describe('parsePath', () => {
|
|
|
149
149
|
test('parsePath handles catch-all param with spread', () => {
|
|
150
150
|
const result = parsePath('users/[...slug]');
|
|
151
151
|
expect(result.path).toBe('/users/*');
|
|
152
|
-
expect(result.params).toEqual(['slug']);
|
|
152
|
+
expect(result.params).toEqual(['...slug']);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
test('parsePath handles catch-all param at root', () => {
|
|
156
156
|
const result = parsePath('[...slug]');
|
|
157
157
|
expect(result.path).toBe('/*');
|
|
158
|
-
expect(result.params).toEqual(['slug']);
|
|
158
|
+
expect(result.params).toEqual(['...slug']);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
test('parsePath preserves param names in path but converts format', () => {
|
package/src/utils.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function parsePath(urlPath: string): { path: string, params: string[] } {
|
|
|
33
33
|
// Dynamic params
|
|
34
34
|
if (ROUTE_SEGMENT_REGEX.test(urlPath)) {
|
|
35
35
|
urlPath = urlPath.replace(ROUTE_SEGMENT_REGEX, (match: string) => {
|
|
36
|
-
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '')
|
|
36
|
+
const paramName = match.replace(/[^a-zA-Z_\.]+/g, '');
|
|
37
37
|
params.push(paramName);
|
|
38
38
|
|
|
39
39
|
if (match.includes('...')) {
|
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
|
-
}
|