@hyperspan/framework 1.0.0-alpha.1 → 1.0.0-alpha.4
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 +7 -3
- package/src/actions.test.ts +206 -0
- package/src/actions.ts +187 -0
- package/src/client/_hs/hyperspan-actions.client.ts +98 -0
- package/src/client/_hs/hyperspan-scripts.client.ts +31 -0
- package/src/client/_hs/hyperspan-streaming.client.ts +94 -0
- package/src/client/js.ts +0 -21
- package/src/index.ts +2 -0
- package/src/plugins.ts +2 -4
- package/src/server.test.ts +19 -16
- package/src/server.ts +21 -12
- package/src/types.ts +25 -1
- package/src/utils.ts +5 -1
- package/tsconfig.json +1 -1
- package/src/clientjs/hyperspan-client.ts +0 -224
- /package/src/{clientjs → client/_hs}/idiomorph.ts +0 -0
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.4",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
5
|
"main": "src/server.ts",
|
|
6
6
|
"types": "src/server.ts",
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"types": "./src/
|
|
14
|
-
"default": "./src/
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"default": "./src/index.ts"
|
|
15
15
|
},
|
|
16
16
|
"./server": {
|
|
17
17
|
"types": "./src/server.ts",
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
"./plugins": {
|
|
45
45
|
"types": "./src/plugins.ts",
|
|
46
46
|
"default": "./src/plugins.ts"
|
|
47
|
+
},
|
|
48
|
+
"./actions": {
|
|
49
|
+
"types": "./src/actions.ts",
|
|
50
|
+
"default": "./src/actions.ts"
|
|
47
51
|
}
|
|
48
52
|
},
|
|
49
53
|
"author": "Vance Lucas <vance@vancelucas.com>",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { test, expect, describe } from 'bun:test';
|
|
2
|
+
import { formDataToJSON, createAction } from './actions';
|
|
3
|
+
import { html, render, type HSHtml } from '@hyperspan/html';
|
|
4
|
+
import { createContext } from './server';
|
|
5
|
+
import type { Hyperspan as HS } from './types';
|
|
6
|
+
import * as z from 'zod/v4';
|
|
7
|
+
|
|
8
|
+
describe('formDataToJSON', () => {
|
|
9
|
+
test('formDataToJSON returns empty object for empty FormData', () => {
|
|
10
|
+
const formData = new FormData();
|
|
11
|
+
const result = formDataToJSON(formData);
|
|
12
|
+
|
|
13
|
+
expect(result).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('formDataToJSON handles simple FormData object', () => {
|
|
17
|
+
const formData = new FormData();
|
|
18
|
+
formData.append('name', 'John Doe');
|
|
19
|
+
formData.append('email', 'john@example.com');
|
|
20
|
+
formData.append('age', '30');
|
|
21
|
+
|
|
22
|
+
const result = formDataToJSON(formData);
|
|
23
|
+
|
|
24
|
+
expect(result).toEqual({
|
|
25
|
+
name: 'John Doe',
|
|
26
|
+
email: 'john@example.com',
|
|
27
|
+
age: '30',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('formDataToJSON handles complex FormData with nested fields', () => {
|
|
32
|
+
const formData = new FormData();
|
|
33
|
+
formData.append('user[firstName]', 'John');
|
|
34
|
+
formData.append('user[lastName]', 'Doe');
|
|
35
|
+
formData.append('user[email]', 'john@example.com');
|
|
36
|
+
formData.append('user[address][street]', '123 Main St');
|
|
37
|
+
formData.append('user[address][city]', 'New York');
|
|
38
|
+
formData.append('user[address][zip]', '10001');
|
|
39
|
+
|
|
40
|
+
const result = formDataToJSON(formData);
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
user: {
|
|
44
|
+
firstName: 'John',
|
|
45
|
+
lastName: 'Doe',
|
|
46
|
+
email: 'john@example.com',
|
|
47
|
+
address: {
|
|
48
|
+
street: '123 Main St',
|
|
49
|
+
city: 'New York',
|
|
50
|
+
zip: '10001',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
} as any);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('formDataToJSON handles FormData with array of values', () => {
|
|
57
|
+
const formData = new FormData();
|
|
58
|
+
formData.append('tags', 'javascript');
|
|
59
|
+
formData.append('tags', 'typescript');
|
|
60
|
+
formData.append('tags', 'nodejs');
|
|
61
|
+
formData.append('colors[]', 'red');
|
|
62
|
+
formData.append('colors[]', 'green');
|
|
63
|
+
formData.append('colors[]', 'blue');
|
|
64
|
+
|
|
65
|
+
const result = formDataToJSON(formData);
|
|
66
|
+
|
|
67
|
+
expect(result).toEqual({
|
|
68
|
+
tags: ['javascript', 'typescript', 'nodejs'],
|
|
69
|
+
colors: ['red', 'green', 'blue'],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('createAction', () => {
|
|
75
|
+
test('creates an action with a simple form and no schema', async () => {
|
|
76
|
+
const action = createAction({
|
|
77
|
+
form: (c: HS.Context) => {
|
|
78
|
+
return html`
|
|
79
|
+
<form>
|
|
80
|
+
<input type="text" name="name" />
|
|
81
|
+
<button type="submit">Submit</button>
|
|
82
|
+
</form>
|
|
83
|
+
`;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(action).toBeDefined();
|
|
88
|
+
expect(action._kind).toBe('hsAction');
|
|
89
|
+
expect(action._route).toContain('/__actions/');
|
|
90
|
+
|
|
91
|
+
// Test render method
|
|
92
|
+
const request = new Request('http://localhost:3000/');
|
|
93
|
+
const context = createContext(request);
|
|
94
|
+
const rendered = render(action.render(context) as HSHtml);
|
|
95
|
+
|
|
96
|
+
expect(rendered).not.toBeNull();
|
|
97
|
+
const htmlString = rendered;
|
|
98
|
+
expect(htmlString).toContain('<hs-action');
|
|
99
|
+
expect(htmlString).toContain('name="name"');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('creates an action with a Zod schema matching form inputs', async () => {
|
|
103
|
+
const schema = z.object({
|
|
104
|
+
name: z.string().min(1, 'Name is required'),
|
|
105
|
+
email: z.email('Invalid email address'),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const action = createAction({
|
|
109
|
+
schema,
|
|
110
|
+
form: (c: HS.Context, { data }) => {
|
|
111
|
+
return html`
|
|
112
|
+
<form>
|
|
113
|
+
<input type="text" name="name" value="${data?.name || ''}" />
|
|
114
|
+
<input type="email" name="email" value="${data?.email || ''}" />
|
|
115
|
+
<button type="submit">Submit</button>
|
|
116
|
+
</form>
|
|
117
|
+
`;
|
|
118
|
+
},
|
|
119
|
+
}).post(async (c: HS.Context, { data }) => {
|
|
120
|
+
return c.res.html(`
|
|
121
|
+
<p>Hello, ${data?.name}!</p>
|
|
122
|
+
<p>Your email is ${data?.email}.</p>
|
|
123
|
+
`);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(action).toBeDefined();
|
|
127
|
+
expect(action._kind).toBe('hsAction');
|
|
128
|
+
expect(action._route).toContain('/__actions/');
|
|
129
|
+
|
|
130
|
+
// Test render method
|
|
131
|
+
const request = new Request('http://localhost:3000/');
|
|
132
|
+
const context = createContext(request);
|
|
133
|
+
const rendered = action.render(context);
|
|
134
|
+
|
|
135
|
+
expect(rendered).not.toBeNull();
|
|
136
|
+
const htmlString = render(rendered as unknown as HSHtml);
|
|
137
|
+
expect(htmlString).toContain('name="name"');
|
|
138
|
+
expect(htmlString).toContain('name="email"');
|
|
139
|
+
|
|
140
|
+
// Test fetch method with POST request to trigger validation
|
|
141
|
+
const formData = new FormData();
|
|
142
|
+
formData.append('name', 'John Doe');
|
|
143
|
+
formData.append('email', 'john@example.com');
|
|
144
|
+
|
|
145
|
+
const postRequest = new Request(`http://localhost:3000${action._route}`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
body: formData,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const response = await action.fetch(postRequest);
|
|
151
|
+
expect(response).toBeInstanceOf(Response);
|
|
152
|
+
expect(response.status).toBe(200);
|
|
153
|
+
|
|
154
|
+
const responseText = await response.text();
|
|
155
|
+
expect(responseText).toContain('Hello, John Doe!');
|
|
156
|
+
expect(responseText).toContain('Your email is john@example.com.');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('re-renders form with error when schema validation fails', async () => {
|
|
160
|
+
const schema = z.object({
|
|
161
|
+
name: z.string().min(1, 'Name is required'),
|
|
162
|
+
email: z.email('Invalid email address'),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const action = createAction({
|
|
166
|
+
schema,
|
|
167
|
+
form: (c: HS.Context, { data, error }) => {
|
|
168
|
+
return html`
|
|
169
|
+
<form>
|
|
170
|
+
<input type="text" name="name" value="${data?.name || ''}" />
|
|
171
|
+
${error ? html`<div class="error">Validation failed</div>` : ''}
|
|
172
|
+
<input type="email" name="email" value="${data?.email || ''}" />
|
|
173
|
+
<button type="submit">Submit</button>
|
|
174
|
+
</form>
|
|
175
|
+
`;
|
|
176
|
+
},
|
|
177
|
+
}).post(async (c: HS.Context, { data }) => {
|
|
178
|
+
return c.res.html(`
|
|
179
|
+
<p>Hello, ${data?.name}!</p>
|
|
180
|
+
<p>Your email is ${data?.email}.</p>
|
|
181
|
+
`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Test fetch method with invalid data (missing name, invalid email)
|
|
185
|
+
const formData = new FormData();
|
|
186
|
+
formData.append('email', 'not-an-email');
|
|
187
|
+
|
|
188
|
+
const postRequest = new Request(`http://localhost:3000${action._route}`, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
body: formData,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const response = await action.fetch(postRequest);
|
|
194
|
+
expect(response).toBeInstanceOf(Response);
|
|
195
|
+
expect(response.status).toBe(400);
|
|
196
|
+
|
|
197
|
+
const responseText = await response.text();
|
|
198
|
+
// Should re-render the form, not the post handler output
|
|
199
|
+
expect(responseText).toContain('name="name"');
|
|
200
|
+
expect(responseText).toContain('name="email"');
|
|
201
|
+
expect(responseText).toContain('Validation failed');
|
|
202
|
+
// Should NOT contain the success message from post handler
|
|
203
|
+
expect(responseText).not.toContain('Hello,');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { html, HSHtml } from '@hyperspan/html';
|
|
2
|
+
import { createRoute, returnHTMLResponse } from './server';
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
import type { Hyperspan as HS } from './types';
|
|
5
|
+
import { assetHash } from './utils';
|
|
6
|
+
import * as actionsClient from './client/_hs/hyperspan-actions.client';
|
|
7
|
+
import { renderClientJS } from './client/js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Actions = Form + route handler
|
|
11
|
+
* Automatically handles and parses form data
|
|
12
|
+
*
|
|
13
|
+
* HOW THIS WORKS:
|
|
14
|
+
* ---
|
|
15
|
+
* 1. Renders in any template as initial form markup with action.render()
|
|
16
|
+
* 2. Binds form onSubmit function to custom client JS handling via <hs-action> web component
|
|
17
|
+
* 3. Submits form with JavaScript fetch() + FormData as normal POST form submission
|
|
18
|
+
* 4. All validation and save logic is run on the server
|
|
19
|
+
* 5. Replaces form content in place with HTML response content from server via the Idiomorph library
|
|
20
|
+
* 6. Handles any Exception thrown on server as error displayed back to user on the page
|
|
21
|
+
*/
|
|
22
|
+
export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }): HS.Action<T> {
|
|
23
|
+
const { name, schema } = params;
|
|
24
|
+
const path = `/__actions/${assetHash(name)}`;
|
|
25
|
+
|
|
26
|
+
let _handler: Parameters<HS.Action<T>['post']>[0] | null = null;
|
|
27
|
+
let _errorHandler: Parameters<HS.Action<T>['errorHandler']>[0] | null = null;
|
|
28
|
+
|
|
29
|
+
const route = createRoute({ path, name })
|
|
30
|
+
.get((c: HS.Context) => api.render(c))
|
|
31
|
+
.post(async (c: HS.Context) => {
|
|
32
|
+
// Parse form data
|
|
33
|
+
const formData = await c.req.raw.formData();
|
|
34
|
+
const jsonData = formDataToJSON(formData) as Partial<z.infer<T>>;
|
|
35
|
+
const schemaData = schema ? schema.safeParse(jsonData) : null;
|
|
36
|
+
const data = schemaData?.success ? (schemaData.data as Partial<z.infer<T>>) : jsonData;
|
|
37
|
+
let error: z.ZodError | Error | null = null;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (schema && schemaData?.error) {
|
|
41
|
+
throw schemaData.error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!_handler) {
|
|
45
|
+
throw new Error('Action POST handler not set! Every action must have a POST handler.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const response = await _handler(c, { data });
|
|
49
|
+
|
|
50
|
+
if (response instanceof Response) {
|
|
51
|
+
// Replace redirects with special header because fetch() automatically follows redirects
|
|
52
|
+
// and we want to redirect the user to the actual full page instead
|
|
53
|
+
if ([301, 302, 307, 308].includes(response.status)) {
|
|
54
|
+
response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
|
|
55
|
+
response.headers.delete('Location');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return response;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
error = e as Error | z.ZodError;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (error && _errorHandler) {
|
|
65
|
+
const errorHandler = _errorHandler; // Required for TypeScript to infer the correct type after narrowing
|
|
66
|
+
return await returnHTMLResponse(c, () => errorHandler(c, { data, error }), {
|
|
67
|
+
status: 400,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const api: HS.Action<T> = {
|
|
75
|
+
_kind: 'hsAction',
|
|
76
|
+
_config: route._config,
|
|
77
|
+
_path() {
|
|
78
|
+
return path;
|
|
79
|
+
},
|
|
80
|
+
_form: null,
|
|
81
|
+
/**
|
|
82
|
+
* Form to render
|
|
83
|
+
* This will be wrapped in a <hs-action> web component and submitted via fetch()
|
|
84
|
+
*/
|
|
85
|
+
form(form: HS.ActionFormHandler<T>) {
|
|
86
|
+
api._form = form;
|
|
87
|
+
return api;
|
|
88
|
+
},
|
|
89
|
+
/**
|
|
90
|
+
* Process form data
|
|
91
|
+
*
|
|
92
|
+
* Returns result from form processing if successful
|
|
93
|
+
* Re-renders form with data and error information otherwise
|
|
94
|
+
*/
|
|
95
|
+
post(handler) {
|
|
96
|
+
_handler = handler;
|
|
97
|
+
return api;
|
|
98
|
+
},
|
|
99
|
+
/**
|
|
100
|
+
* Get form renderer method
|
|
101
|
+
*/
|
|
102
|
+
render(c: HS.Context, props?: HS.ActionProps<T>) {
|
|
103
|
+
const formContent = api._form ? api._form(c, props || {}) : null;
|
|
104
|
+
return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${renderClientJS(actionsClient)}` : null;
|
|
105
|
+
},
|
|
106
|
+
errorHandler(handler) {
|
|
107
|
+
_errorHandler = handler;
|
|
108
|
+
return api;
|
|
109
|
+
},
|
|
110
|
+
middleware(middleware: Array<HS.MiddlewareFunction>) {
|
|
111
|
+
route.middleware(middleware);
|
|
112
|
+
return api;
|
|
113
|
+
},
|
|
114
|
+
fetch: route.fetch,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return api;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Return JSON data structure for a given FormData object
|
|
122
|
+
* Accounts for array fields (e.g. name="options[]" or <select multiple>)
|
|
123
|
+
*
|
|
124
|
+
* @link https://stackoverflow.com/a/75406413
|
|
125
|
+
*/
|
|
126
|
+
export function formDataToJSON(formData: FormData): Record<string, string | string[]> {
|
|
127
|
+
let object = {};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parses FormData key xxx`[x][x][x]` fields into array
|
|
131
|
+
*/
|
|
132
|
+
const parseKey = (key: string) => {
|
|
133
|
+
const subKeyIdx = key.indexOf('[');
|
|
134
|
+
|
|
135
|
+
if (subKeyIdx !== -1) {
|
|
136
|
+
const keys = [key.substring(0, subKeyIdx)];
|
|
137
|
+
key = key.substring(subKeyIdx);
|
|
138
|
+
|
|
139
|
+
for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
|
|
140
|
+
if (match.groups) {
|
|
141
|
+
keys.push(match.groups.key);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return keys;
|
|
145
|
+
} else {
|
|
146
|
+
return [key];
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Recursively iterates over keys and assigns key/values to object
|
|
152
|
+
*/
|
|
153
|
+
const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
|
|
154
|
+
const key = keys.shift();
|
|
155
|
+
|
|
156
|
+
// When last key in the iterations
|
|
157
|
+
if (key === '' || key === undefined) {
|
|
158
|
+
return object.push(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (Reflect.has(object, key)) {
|
|
162
|
+
// If key has been found, but final pass - convert the value to array
|
|
163
|
+
if (keys.length === 0) {
|
|
164
|
+
if (!Array.isArray(object[key])) {
|
|
165
|
+
object[key] = [object[key], value];
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Recurse again with found object
|
|
170
|
+
return assign(keys, value, object[key]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Create empty object for key, if next key is '' do array instead, otherwise set value
|
|
174
|
+
if (keys.length >= 1) {
|
|
175
|
+
object[key] = keys[0] === '' ? [] : {};
|
|
176
|
+
return assign(keys, value, object[key]);
|
|
177
|
+
} else {
|
|
178
|
+
object[key] = value;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const pair of formData.entries()) {
|
|
183
|
+
assign(parseKey(pair[0]), pair[1], object);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return object;
|
|
187
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Idiomorph } from './idiomorph';
|
|
2
|
+
import { lazyLoadScripts } from './hyperspan-scripts.client';
|
|
3
|
+
|
|
4
|
+
const actionFormObserver = new MutationObserver((list) => {
|
|
5
|
+
list.forEach((mutation) => {
|
|
6
|
+
mutation.addedNodes.forEach((node) => {
|
|
7
|
+
if (node && ('closest' in node || node instanceof HTMLFormElement)) {
|
|
8
|
+
bindHSActionForm(
|
|
9
|
+
(node as HTMLElement).closest('hs-action') as HSAction,
|
|
10
|
+
node instanceof HTMLFormElement
|
|
11
|
+
? node
|
|
12
|
+
: ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Server action component to handle the client-side form submission and HTML replacement
|
|
21
|
+
*/
|
|
22
|
+
class HSAction extends HTMLElement {
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
connectedCallback() {
|
|
28
|
+
actionFormObserver.observe(this, { childList: true, subtree: true });
|
|
29
|
+
bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
window.customElements.define('hs-action', HSAction);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bind the form inside an hs-action element to the action URL and submit handler
|
|
36
|
+
*/
|
|
37
|
+
function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
|
|
38
|
+
if (!hsActionElement || !form) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
form.setAttribute('action', hsActionElement.getAttribute('url') || '');
|
|
43
|
+
const submitHandler = (e: Event) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
formSubmitToRoute(e, form as HTMLFormElement, {
|
|
46
|
+
afterResponse: () => bindHSActionForm(hsActionElement, form),
|
|
47
|
+
});
|
|
48
|
+
form.removeEventListener('submit', submitHandler);
|
|
49
|
+
};
|
|
50
|
+
form.addEventListener('submit', submitHandler);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Submit form data to route and replace contents with response
|
|
55
|
+
*/
|
|
56
|
+
type TFormSubmitOptons = { afterResponse: () => any };
|
|
57
|
+
function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
|
|
58
|
+
const formData = new FormData(form);
|
|
59
|
+
const formUrl = form.getAttribute('action') || '';
|
|
60
|
+
const method = form.getAttribute('method')?.toUpperCase() || 'POST';
|
|
61
|
+
const headers = {
|
|
62
|
+
Accept: 'text/html',
|
|
63
|
+
'X-Request-Type': 'partial',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const hsActionTag = form.closest('hs-action');
|
|
67
|
+
const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
|
|
68
|
+
if (submitBtn) {
|
|
69
|
+
submitBtn.setAttribute('disabled', 'disabled');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fetch(formUrl, { body: formData, method, headers })
|
|
73
|
+
.then((res: Response) => {
|
|
74
|
+
// Look for special header that indicates a redirect.
|
|
75
|
+
// fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
|
|
76
|
+
if (res.headers.has('X-Redirect-Location')) {
|
|
77
|
+
const newUrl = res.headers.get('X-Redirect-Location');
|
|
78
|
+
if (newUrl) {
|
|
79
|
+
window.location.assign(newUrl);
|
|
80
|
+
}
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return res.text();
|
|
85
|
+
})
|
|
86
|
+
.then((content: string) => {
|
|
87
|
+
// No content = DO NOTHING (redirect or something else happened)
|
|
88
|
+
if (!content) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const target = content.includes('<html') ? window.document.body : hsActionTag || form;
|
|
93
|
+
|
|
94
|
+
Idiomorph.morph(target, content);
|
|
95
|
+
opts.afterResponse && opts.afterResponse();
|
|
96
|
+
lazyLoadScripts();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intersection observer for lazy loading <script> tags
|
|
3
|
+
*/
|
|
4
|
+
const lazyLoadScriptObserver = new IntersectionObserver(
|
|
5
|
+
(entries, observer) => {
|
|
6
|
+
entries
|
|
7
|
+
.filter((entry) => entry.isIntersecting)
|
|
8
|
+
.forEach((entry) => {
|
|
9
|
+
observer.unobserve(entry.target);
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
if (entry.target.children[0]?.content) {
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
entry.target.replaceWith(entry.target.children[0].content);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
{ rootMargin: '0px 0px -200px 0px' }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lazy load <script> tags in the current document
|
|
22
|
+
*/
|
|
23
|
+
export function lazyLoadScripts() {
|
|
24
|
+
document
|
|
25
|
+
.querySelectorAll('div[data-loading=lazy]')
|
|
26
|
+
.forEach((el) => lazyLoadScriptObserver.observe(el));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
window.addEventListener('load', () => {
|
|
30
|
+
lazyLoadScripts();
|
|
31
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Idiomorph } from './idiomorph';
|
|
2
|
+
import { lazyLoadScripts } from './hyperspan-scripts.client';
|
|
3
|
+
|
|
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
|
+
/**
|
|
67
|
+
* Wait until ALL of the content inside an element is present from streaming in.
|
|
68
|
+
* Large chunks of content can sometimes take more than a single tick to write to DOM.
|
|
69
|
+
*/
|
|
70
|
+
async function waitForContent(
|
|
71
|
+
el: HTMLElement,
|
|
72
|
+
waitFn: (
|
|
73
|
+
node: HTMLElement
|
|
74
|
+
) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
|
|
75
|
+
options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
|
|
76
|
+
): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
let timeout: NodeJS.Timeout;
|
|
79
|
+
const interval = setInterval(() => {
|
|
80
|
+
const content = waitFn(el);
|
|
81
|
+
if (content) {
|
|
82
|
+
if (timeout) {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
}
|
|
85
|
+
clearInterval(interval);
|
|
86
|
+
resolve(content);
|
|
87
|
+
}
|
|
88
|
+
}, options.intervalMs || 20);
|
|
89
|
+
timeout = setTimeout(() => {
|
|
90
|
+
clearInterval(interval);
|
|
91
|
+
reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
|
|
92
|
+
}, options.timeoutMs || 10000);
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/client/js.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
|
-
import type { Hyperspan as HS } from '../types';
|
|
3
|
-
|
|
4
2
|
|
|
5
3
|
export const JS_PUBLIC_PATH = '/_hs/js';
|
|
6
4
|
export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
|
|
@@ -60,23 +58,4 @@ export function functionToString(fn: any) {
|
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
return str;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Island defaults
|
|
67
|
-
*/
|
|
68
|
-
export const ISLAND_DEFAULTS: () => HS.ClientIslandOptions = () => ({
|
|
69
|
-
ssr: true,
|
|
70
|
-
loading: undefined,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
export function renderIsland(Component: any, props: any, options = ISLAND_DEFAULTS()) {
|
|
74
|
-
// Render island with its own logic
|
|
75
|
-
if (Component.__HS_ISLAND?.render) {
|
|
76
|
-
return html.raw(Component.__HS_ISLAND.render(props, options));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
throw new Error(
|
|
80
|
-
`Module ${Component.name} was not loaded with an island plugin! Did you forget to install an island plugin and add it to the 'islandPlugins' option in your hyperspan.config.ts file?`
|
|
81
|
-
);
|
|
82
61
|
}
|
package/src/index.ts
ADDED
package/src/plugins.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function clientJSPlugin(): HS.Plugin {
|
|
|
46
46
|
const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
|
|
47
47
|
JS_IMPORT_MAP.set(esmName, `${JS_PUBLIC_PATH}/${esmName}.js`);
|
|
48
48
|
|
|
49
|
+
// Get the contents of the file to extract the exports
|
|
49
50
|
const contents = await result.outputs[0].text();
|
|
50
51
|
const exportLine = EXPORT_REGEX.exec(contents);
|
|
51
52
|
|
|
@@ -65,10 +66,7 @@ export function clientJSPlugin(): HS.Plugin {
|
|
|
65
66
|
|
|
66
67
|
// Export a special object that can be used to render the client JS as a script tag
|
|
67
68
|
const moduleCode = `// hyperspan:processed
|
|
68
|
-
import { functionToString } from '@hyperspan/framework/
|
|
69
|
-
|
|
70
|
-
// Original file contents
|
|
71
|
-
${contents}
|
|
69
|
+
import { functionToString } from '@hyperspan/framework/client/js';
|
|
72
70
|
|
|
73
71
|
// hyperspan:client-js-plugin
|
|
74
72
|
export const __CLIENT_JS = {
|
package/src/server.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createRoute, createServer } from './server';
|
|
|
3
3
|
import type { Hyperspan as HS } from './types';
|
|
4
4
|
|
|
5
5
|
test('route fetch() returns a Response', async () => {
|
|
6
|
-
const route = createRoute().get((context) => {
|
|
6
|
+
const route = createRoute().get((context: HS.Context) => {
|
|
7
7
|
return context.res.html('<h1>Hello World</h1>');
|
|
8
8
|
});
|
|
9
9
|
|
|
@@ -16,24 +16,25 @@ test('route fetch() returns a Response', async () => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
test('server with two routes can return Response from one', async () => {
|
|
19
|
-
const server = createServer({
|
|
19
|
+
const server = await createServer({
|
|
20
20
|
appDir: './app',
|
|
21
|
-
|
|
21
|
+
publicDir: './public',
|
|
22
|
+
plugins: [],
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
// Add two routes to the server
|
|
25
|
-
server.get('/users', (context) => {
|
|
26
|
+
server.get('/users', (context: HS.Context) => {
|
|
26
27
|
return context.res.html('<h1>Users Page</h1>');
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
server.get('/posts', (context) => {
|
|
30
|
+
server.get('/posts', (context: HS.Context) => {
|
|
30
31
|
return context.res.html('<h1>Posts Page</h1>');
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
// Test that we can get a Response from one of the routes
|
|
35
36
|
const request = new Request('http://localhost:3000/users');
|
|
36
|
-
const testRoute = server._routes.find((route) => route._path === '/users');
|
|
37
|
+
const testRoute = server._routes.find((route: HS.Route) => route._path() === '/users');
|
|
37
38
|
const response = await testRoute!.fetch(request);
|
|
38
39
|
|
|
39
40
|
expect(response).toBeInstanceOf(Response);
|
|
@@ -42,22 +43,23 @@ test('server with two routes can return Response from one', async () => {
|
|
|
42
43
|
});
|
|
43
44
|
|
|
44
45
|
test('server returns a route with a POST request', async () => {
|
|
45
|
-
const server = createServer({
|
|
46
|
+
const server = await createServer({
|
|
46
47
|
appDir: './app',
|
|
47
|
-
|
|
48
|
+
publicDir: './public',
|
|
49
|
+
plugins: [],
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
// Add two routes to the server
|
|
51
|
-
server.get('/users', (context) => {
|
|
53
|
+
server.get('/users', (context: HS.Context) => {
|
|
52
54
|
return context.res.html('<h1>GET /users</h1>');
|
|
53
55
|
});
|
|
54
56
|
|
|
55
|
-
server.post('/users', (context) => {
|
|
57
|
+
server.post('/users', (context: HS.Context) => {
|
|
56
58
|
return context.res.html('<h1>POST /users</h1>');
|
|
57
59
|
});
|
|
58
60
|
|
|
59
|
-
const route = server._routes.find((route) => route._path === '/users' && route._methods().includes('POST')) as HS.Route;
|
|
60
|
-
const request = new Request('http://localhost:3000/', { method: 'POST' });
|
|
61
|
+
const route = server._routes.find((route: HS.Route) => route._path() === '/users' && route._methods().includes('POST')) as HS.Route;
|
|
62
|
+
const request = new Request('http://localhost:3000/users', { method: 'POST' });
|
|
61
63
|
const response = await route.fetch(request);
|
|
62
64
|
|
|
63
65
|
expect(response).toBeInstanceOf(Response);
|
|
@@ -66,18 +68,19 @@ test('server returns a route with a POST request', async () => {
|
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
test('returns 405 when route path matches but HTTP method does not', async () => {
|
|
69
|
-
const server = createServer({
|
|
71
|
+
const server = await createServer({
|
|
70
72
|
appDir: './app',
|
|
71
|
-
|
|
73
|
+
publicDir: './public',
|
|
74
|
+
plugins: [],
|
|
72
75
|
});
|
|
73
76
|
|
|
74
77
|
// Route registered for GET only
|
|
75
|
-
server.get('/users', (context) => {
|
|
78
|
+
server.get('/users', (context: HS.Context) => {
|
|
76
79
|
return context.res.html('<h1>Users Page</h1>');
|
|
77
80
|
});
|
|
78
81
|
|
|
79
82
|
// Attempt to POST to /users, which should return 405
|
|
80
|
-
const route = server._routes.find((route) => route._path === '/users')!;
|
|
83
|
+
const route = server._routes.find((route: HS.Route) => route._path() === '/users')!;
|
|
81
84
|
const request = new Request('http://localhost:3000/users', { method: 'POST' });
|
|
82
85
|
const response = await route.fetch(request);
|
|
83
86
|
|
package/src/server.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { clientJSPlugin } from './plugins';
|
|
|
5
5
|
export type { HS as Hyperspan };
|
|
6
6
|
|
|
7
7
|
export const IS_PROD = process.env.NODE_ENV === 'production';
|
|
8
|
-
const CWD = process.cwd();
|
|
9
8
|
|
|
10
9
|
export class HTTPException extends Error {
|
|
11
10
|
constructor(public status: number, message?: string) {
|
|
@@ -38,9 +37,11 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
38
37
|
const params: HS.RouteParamsParser<path> = req?.params || {};
|
|
39
38
|
|
|
40
39
|
return {
|
|
40
|
+
vars: {},
|
|
41
41
|
route: {
|
|
42
42
|
path,
|
|
43
43
|
params: params,
|
|
44
|
+
cssImports: route ? route._config.cssImports ?? [] : [],
|
|
44
45
|
},
|
|
45
46
|
req: {
|
|
46
47
|
raw: req,
|
|
@@ -51,6 +52,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
51
52
|
body: req.body,
|
|
52
53
|
},
|
|
53
54
|
res: {
|
|
55
|
+
headers: new Headers(),
|
|
54
56
|
raw: new Response(),
|
|
55
57
|
html: (html: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } }),
|
|
56
58
|
json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } }),
|
|
@@ -132,6 +134,10 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
132
134
|
_middleware['OPTIONS'] = handlerOptions?.middleware || [];
|
|
133
135
|
return api;
|
|
134
136
|
},
|
|
137
|
+
errorHandler(handler: HS.RouteHandler) {
|
|
138
|
+
_handlers['_ERROR'] = handler;
|
|
139
|
+
return api;
|
|
140
|
+
},
|
|
135
141
|
/**
|
|
136
142
|
* Add middleware specific to this route
|
|
137
143
|
*/
|
|
@@ -145,12 +151,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
145
151
|
*/
|
|
146
152
|
async fetch(request: Request) {
|
|
147
153
|
const context = createContext(request, api);
|
|
154
|
+
const method = context.req.method;
|
|
148
155
|
const globalMiddleware = _middleware['*'] || [];
|
|
149
|
-
const methodMiddleware = _middleware[
|
|
156
|
+
const methodMiddleware = _middleware[method] || [];
|
|
150
157
|
|
|
151
158
|
const methodHandler = async (context: HS.Context) => {
|
|
152
|
-
const method = context.req.method;
|
|
153
|
-
|
|
154
159
|
// Handle CORS preflight requests (if no OPTIONS handler is defined)
|
|
155
160
|
if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
|
|
156
161
|
return context.res.html(
|
|
@@ -194,7 +199,16 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
|
|
|
194
199
|
return routeContent;
|
|
195
200
|
};
|
|
196
201
|
|
|
197
|
-
|
|
202
|
+
// Run the route handler and any middleware
|
|
203
|
+
// If an error occurs, run the error handler if it exists
|
|
204
|
+
try {
|
|
205
|
+
return await executeMiddleware(context, [...globalMiddleware, ...methodMiddleware, methodHandler]);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (_handlers['_ERROR']) {
|
|
208
|
+
return await (_handlers['_ERROR'](context) as Promise<Response>);
|
|
209
|
+
}
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
198
212
|
},
|
|
199
213
|
};
|
|
200
214
|
|
|
@@ -327,11 +341,6 @@ export function getRunnableRoute(route: unknown, routeConfig?: HS.RouteConfig):
|
|
|
327
341
|
|
|
328
342
|
const kind = typeof route;
|
|
329
343
|
|
|
330
|
-
// Plain function - wrap in createRoute()
|
|
331
|
-
if (kind === 'function') {
|
|
332
|
-
return createRoute(routeConfig).get(route as HS.RouteHandler);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
344
|
// Module - get default and use it
|
|
336
345
|
// @ts-ignore
|
|
337
346
|
if (kind === 'object' && 'default' in route) {
|
|
@@ -353,7 +362,7 @@ export function isRunnableRoute(route: unknown): boolean {
|
|
|
353
362
|
}
|
|
354
363
|
|
|
355
364
|
const obj = route as { _kind: string; fetch: (request: Request) => Promise<Response> };
|
|
356
|
-
return
|
|
365
|
+
return typeof obj?._kind === 'string' && 'fetch' in obj;
|
|
357
366
|
}
|
|
358
367
|
|
|
359
368
|
/**
|
|
@@ -363,7 +372,7 @@ export function isValidRoutePath(path: string): boolean {
|
|
|
363
372
|
const isHiddenRoute = path.includes('/__');
|
|
364
373
|
const isTestFile = path.includes('.test') || path.includes('.spec');
|
|
365
374
|
|
|
366
|
-
return !isHiddenRoute && !isTestFile;
|
|
375
|
+
return !isHiddenRoute && !isTestFile && Boolean(path);
|
|
367
376
|
}
|
|
368
377
|
|
|
369
378
|
/**
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { HSHtml } from '@hyperspan/html';
|
|
2
|
+
import * as z from 'zod/v4';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Hyperspan Types
|
|
3
6
|
*/
|
|
@@ -94,7 +97,6 @@ export namespace Hyperspan {
|
|
|
94
97
|
|
|
95
98
|
export interface Route {
|
|
96
99
|
_kind: 'hsRoute';
|
|
97
|
-
_name: string | undefined;
|
|
98
100
|
_config: Hyperspan.RouteConfig;
|
|
99
101
|
_path(): string;
|
|
100
102
|
_methods(): string[];
|
|
@@ -104,7 +106,29 @@ export namespace Hyperspan {
|
|
|
104
106
|
patch: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
105
107
|
delete: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
106
108
|
options: (handler: Hyperspan.RouteHandler, handlerOptions?: Hyperspan.RouteHandlerOptions) => Hyperspan.Route;
|
|
109
|
+
errorHandler: (handler: Hyperspan.RouteHandler) => Hyperspan.Route;
|
|
107
110
|
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Hyperspan.Route;
|
|
108
111
|
fetch: (request: Request) => Promise<Response>;
|
|
109
112
|
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Action = Form + route handler
|
|
116
|
+
*/
|
|
117
|
+
export type ActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
|
|
118
|
+
export type ActionProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: z.ZodError | Error };
|
|
119
|
+
export type ActionFormHandler<T extends z.ZodTypeAny> = (
|
|
120
|
+
c: Context, props: ActionProps<T>
|
|
121
|
+
) => ActionResponse;
|
|
122
|
+
export interface Action<T extends z.ZodTypeAny> {
|
|
123
|
+
_kind: 'hsAction';
|
|
124
|
+
_config: Hyperspan.RouteConfig;
|
|
125
|
+
_path(): string;
|
|
126
|
+
_form: null | ActionFormHandler<T>;
|
|
127
|
+
form(form: ActionFormHandler<T>): Action<T>;
|
|
128
|
+
render: (c: Context, props?: ActionProps<T>) => ActionResponse;
|
|
129
|
+
post: (handler: ActionFormHandler<T>) => Action<T>;
|
|
130
|
+
errorHandler: (handler: ActionFormHandler<T>) => Action<T>;
|
|
131
|
+
middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
|
|
132
|
+
fetch: (request: Request) => Promise<Response>;
|
|
133
|
+
}
|
|
110
134
|
}
|
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
export function assetHash(content: string): string {
|
|
4
4
|
return createHash('md5').update(content).digest('hex');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function randomHash(): string {
|
|
8
|
+
return createHash('md5').update(randomBytes(32).toString('hex')).digest('hex');
|
|
5
9
|
}
|
package/tsconfig.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compileOnSave": true,
|
|
3
3
|
"compilerOptions": {
|
|
4
|
-
"rootDir": "src",
|
|
5
4
|
"outDir": "dist",
|
|
6
5
|
"target": "es2019",
|
|
7
6
|
"lib": ["ESNext", "dom", "dom.iterable"],
|
|
@@ -25,5 +24,6 @@
|
|
|
25
24
|
"@hyperspan/html": ["../html/src/html.ts"]
|
|
26
25
|
}
|
|
27
26
|
},
|
|
27
|
+
"references": [{ "path": "../html" }],
|
|
28
28
|
"exclude": ["node_modules", "__tests__", "*.test.ts"]
|
|
29
29
|
}
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { html } from '@hyperspan/html';
|
|
2
|
-
import { Idiomorph } from './idiomorph';
|
|
3
|
-
|
|
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
|
-
/**
|
|
67
|
-
* Wait until ALL of the content inside an element is present from streaming in.
|
|
68
|
-
* Large chunks of content can sometimes take more than a single tick to write to DOM.
|
|
69
|
-
*/
|
|
70
|
-
async function waitForContent(
|
|
71
|
-
el: HTMLElement,
|
|
72
|
-
waitFn: (
|
|
73
|
-
node: HTMLElement
|
|
74
|
-
) => HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined,
|
|
75
|
-
options: { timeoutMs?: number; intervalMs?: number } = { timeoutMs: 10000, intervalMs: 20 }
|
|
76
|
-
): Promise<HTMLElement | HTMLTemplateElement | Node | ChildNode | null | undefined> {
|
|
77
|
-
return new Promise((resolve, reject) => {
|
|
78
|
-
let timeout: NodeJS.Timeout;
|
|
79
|
-
const interval = setInterval(() => {
|
|
80
|
-
const content = waitFn(el);
|
|
81
|
-
if (content) {
|
|
82
|
-
if (timeout) {
|
|
83
|
-
clearTimeout(timeout);
|
|
84
|
-
}
|
|
85
|
-
clearInterval(interval);
|
|
86
|
-
resolve(content);
|
|
87
|
-
}
|
|
88
|
-
}, options.intervalMs || 20);
|
|
89
|
-
timeout = setTimeout(() => {
|
|
90
|
-
clearInterval(interval);
|
|
91
|
-
reject(new Error(`[Hyperspan] Timeout waiting for end of streaming content ${el.id}`));
|
|
92
|
-
}, options.timeoutMs || 10000);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Server action component to handle the client-side form submission and HTML replacement
|
|
98
|
-
*/
|
|
99
|
-
class HSAction extends HTMLElement {
|
|
100
|
-
constructor() {
|
|
101
|
-
super();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
connectedCallback() {
|
|
105
|
-
actionFormObserver.observe(this, { childList: true, subtree: true });
|
|
106
|
-
bindHSActionForm(this, this.querySelector('form') as HTMLFormElement);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
window.customElements.define('hs-action', HSAction);
|
|
110
|
-
const actionFormObserver = new MutationObserver((list) => {
|
|
111
|
-
list.forEach((mutation) => {
|
|
112
|
-
mutation.addedNodes.forEach((node) => {
|
|
113
|
-
if (node && ('closest' in node || node instanceof HTMLFormElement)) {
|
|
114
|
-
bindHSActionForm(
|
|
115
|
-
(node as HTMLElement).closest('hs-action') as HSAction,
|
|
116
|
-
node instanceof HTMLFormElement
|
|
117
|
-
? node
|
|
118
|
-
: ((node as HTMLElement | HTMLFormElement).querySelector('form') as HTMLFormElement)
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Bind the form inside an hs-action element to the action URL and submit handler
|
|
127
|
-
*/
|
|
128
|
-
function bindHSActionForm(hsActionElement: HSAction, form: HTMLFormElement) {
|
|
129
|
-
if (!hsActionElement || !form) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
form.setAttribute('action', hsActionElement.getAttribute('url') || '');
|
|
134
|
-
const submitHandler = (e: Event) => {
|
|
135
|
-
e.preventDefault();
|
|
136
|
-
formSubmitToRoute(e, form as HTMLFormElement, {
|
|
137
|
-
afterResponse: () => bindHSActionForm(hsActionElement, form),
|
|
138
|
-
});
|
|
139
|
-
form.removeEventListener('submit', submitHandler);
|
|
140
|
-
};
|
|
141
|
-
form.addEventListener('submit', submitHandler);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Submit form data to route and replace contents with response
|
|
146
|
-
*/
|
|
147
|
-
type TFormSubmitOptons = { afterResponse: () => any };
|
|
148
|
-
function formSubmitToRoute(e: Event, form: HTMLFormElement, opts: TFormSubmitOptons) {
|
|
149
|
-
const formData = new FormData(form);
|
|
150
|
-
const formUrl = form.getAttribute('action') || '';
|
|
151
|
-
const method = form.getAttribute('method')?.toUpperCase() || 'POST';
|
|
152
|
-
const headers = {
|
|
153
|
-
Accept: 'text/html',
|
|
154
|
-
'X-Request-Type': 'partial',
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const hsActionTag = form.closest('hs-action');
|
|
158
|
-
const submitBtn = form.querySelector('button[type=submit],input[type=submit]');
|
|
159
|
-
if (submitBtn) {
|
|
160
|
-
submitBtn.setAttribute('disabled', 'disabled');
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
fetch(formUrl, { body: formData, method, headers })
|
|
164
|
-
.then((res: Response) => {
|
|
165
|
-
// Look for special header that indicates a redirect.
|
|
166
|
-
// fetch() automatically follows 3xx redirects, so we need to handle this manually to redirect the user to the full page
|
|
167
|
-
if (res.headers.has('X-Redirect-Location')) {
|
|
168
|
-
const newUrl = res.headers.get('X-Redirect-Location');
|
|
169
|
-
if (newUrl) {
|
|
170
|
-
window.location.assign(newUrl);
|
|
171
|
-
}
|
|
172
|
-
return '';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return res.text();
|
|
176
|
-
})
|
|
177
|
-
.then((content: string) => {
|
|
178
|
-
// No content = DO NOTHING (redirect or something else happened)
|
|
179
|
-
if (!content) {
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const target = content.includes('<html') ? window.document.body : hsActionTag || form;
|
|
184
|
-
|
|
185
|
-
Idiomorph.morph(target, content);
|
|
186
|
-
opts.afterResponse && opts.afterResponse();
|
|
187
|
-
lazyLoadScripts();
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Intersection observer for lazy loading <script> tags
|
|
193
|
-
*/
|
|
194
|
-
const lazyLoadScriptObserver = new IntersectionObserver(
|
|
195
|
-
(entries, observer) => {
|
|
196
|
-
entries
|
|
197
|
-
.filter((entry) => entry.isIntersecting)
|
|
198
|
-
.forEach((entry) => {
|
|
199
|
-
observer.unobserve(entry.target);
|
|
200
|
-
// @ts-ignore
|
|
201
|
-
if (entry.target.children[0]?.content) {
|
|
202
|
-
// @ts-ignore
|
|
203
|
-
entry.target.replaceWith(entry.target.children[0].content);
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
},
|
|
207
|
-
{ rootMargin: '0px 0px -200px 0px' }
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Lazy load <script> tags in the current document
|
|
212
|
-
*/
|
|
213
|
-
function lazyLoadScripts() {
|
|
214
|
-
document
|
|
215
|
-
.querySelectorAll('div[data-loading=lazy]')
|
|
216
|
-
.forEach((el) => lazyLoadScriptObserver.observe(el));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
window.addEventListener('load', () => {
|
|
220
|
-
lazyLoadScripts();
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// @ts-ignore
|
|
224
|
-
window.html = html;
|
|
File without changes
|