@hyperspan/framework 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/actions.test.ts +52 -1
- package/src/actions.ts +27 -37
- package/src/middleware.ts +5 -6
- package/src/server.ts +12 -7
- package/src/types.ts +1 -1
- package/src/client/js.test.ts +0 -200
package/package.json
CHANGED
package/src/actions.test.ts
CHANGED
|
@@ -126,7 +126,7 @@ describe('createAction', () => {
|
|
|
126
126
|
const formData = new FormData();
|
|
127
127
|
formData.append('email', 'not-an-email');
|
|
128
128
|
|
|
129
|
-
const postRequest = new Request(`http://localhost:3000${action.
|
|
129
|
+
const postRequest = new Request(`http://localhost:3000${action._path()}`, {
|
|
130
130
|
method: 'POST',
|
|
131
131
|
body: formData,
|
|
132
132
|
});
|
|
@@ -143,5 +143,56 @@ describe('createAction', () => {
|
|
|
143
143
|
// Should NOT contain the success message from post handler
|
|
144
144
|
expect(responseText).not.toContain('Hello,');
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
test('uses custom error handler when provided', async () => {
|
|
148
|
+
const schema = z.object({
|
|
149
|
+
name: z.string().min(1, 'Name is required'),
|
|
150
|
+
email: z.email('Invalid email address'),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const action = createAction({
|
|
154
|
+
name: 'test',
|
|
155
|
+
schema,
|
|
156
|
+
}).form((c, { data, error }) => {
|
|
157
|
+
return html`
|
|
158
|
+
<form>
|
|
159
|
+
<input type="text" name="name" value="${data?.name || ''}" />
|
|
160
|
+
${error ? html`<div class="error">Validation failed</div>` : ''}
|
|
161
|
+
<input type="email" name="email" value="${data?.email || ''}" />
|
|
162
|
+
<button type="submit">Submit</button>
|
|
163
|
+
</form>
|
|
164
|
+
`;
|
|
165
|
+
}).post(async (c, { data }) => {
|
|
166
|
+
return c.res.html(`
|
|
167
|
+
<p>Hello, ${data?.name}!</p>
|
|
168
|
+
<p>Your email is ${data?.email}.</p>
|
|
169
|
+
`);
|
|
170
|
+
}).errorHandler(async (c, { data, error }) => {
|
|
171
|
+
return c.res.html(`
|
|
172
|
+
<p>Caught error in custom error handler: ${error?.message}</p>
|
|
173
|
+
<p>Data: ${JSON.stringify(data)}</p>
|
|
174
|
+
`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Test fetch method with invalid data (missing name, invalid email)
|
|
178
|
+
const formData = new FormData();
|
|
179
|
+
formData.append('email', 'not-an-email');
|
|
180
|
+
|
|
181
|
+
const postRequest = new Request(`http://localhost:3000${action._path()}`, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
body: formData,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const response = await action.fetch(postRequest);
|
|
187
|
+
expect(response).toBeInstanceOf(Response);
|
|
188
|
+
expect(response.status).toBe(400);
|
|
189
|
+
|
|
190
|
+
const responseText = await response.text();
|
|
191
|
+
// Should render the custom error handler
|
|
192
|
+
expect(responseText).toContain('Caught error in custom error handler: Input validation error(s)');
|
|
193
|
+
expect(responseText).toContain('Data: {"email":"not-an-email"}');
|
|
194
|
+
// Should NOT contain the success message from post handler
|
|
195
|
+
expect(responseText).not.toContain('Hello,');
|
|
196
|
+
});
|
|
146
197
|
});
|
|
147
198
|
|
package/src/actions.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { html } from '@hyperspan/html';
|
|
2
|
-
import { createRoute, returnHTMLResponse } from './server';
|
|
2
|
+
import { createRoute, HTTPResponseException, 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
6
|
import { buildClientJS } from './client/js';
|
|
7
|
-
import { validateBody } from './middleware';
|
|
7
|
+
import { validateBody, ZodValidationError } from './middleware';
|
|
8
8
|
|
|
9
9
|
const actionsClientJS = await buildClientJS(import.meta.resolve('./client/_hs/hyperspan-actions.client'));
|
|
10
10
|
|
|
@@ -31,47 +31,37 @@ export function createAction<T extends z.ZodObject<any, any>>(params: { name: st
|
|
|
31
31
|
const route = createRoute({ path, name })
|
|
32
32
|
.get((c: HS.Context) => api.render(c))
|
|
33
33
|
.post(async (c: HS.Context) => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const schemaData = schema ? schema.safeParse(jsonData) : null;
|
|
38
|
-
const data = schemaData?.success ? (schemaData.data as Partial<z.infer<T>>) : jsonData;
|
|
39
|
-
let error: z.ZodError | Error | null = null;
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
if (schema && schemaData?.error) {
|
|
43
|
-
throw schemaData.error;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (!_handler) {
|
|
47
|
-
throw new Error('Action POST handler not set! Every action must have a POST handler.');
|
|
48
|
-
}
|
|
34
|
+
if (!_handler) {
|
|
35
|
+
throw new Error('Action POST handler not set! Every action must have a POST handler.');
|
|
36
|
+
}
|
|
49
37
|
|
|
50
|
-
|
|
38
|
+
const response = await _handler(c, { data: c.vars.body });
|
|
51
39
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
40
|
+
if (response instanceof Response) {
|
|
41
|
+
// Replace redirects with special header because fetch() automatically follows redirects
|
|
42
|
+
// and we want to redirect the user to the actual full page instead
|
|
43
|
+
if ([301, 302, 307, 308].includes(response.status)) {
|
|
44
|
+
response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
|
|
45
|
+
response.headers.delete('Location');
|
|
59
46
|
}
|
|
60
|
-
|
|
61
|
-
return response;
|
|
62
|
-
} catch (e) {
|
|
63
|
-
error = e as Error | z.ZodError;
|
|
64
47
|
}
|
|
65
48
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
49
|
+
return response;
|
|
50
|
+
}, { middleware: schema ? [validateBody(schema)] : [] })
|
|
51
|
+
/**
|
|
52
|
+
* Custom error handler for the action since validateBody() throws a HTTPResponseException
|
|
53
|
+
*/
|
|
54
|
+
.errorHandler(async (c: HS.Context, err: HTTPResponseException) => {
|
|
55
|
+
const data = c.vars.body as Partial<z.infer<T>>;
|
|
56
|
+
const error = err._error as ZodValidationError;
|
|
57
|
+
|
|
58
|
+
// Set the status to 400 by default
|
|
59
|
+
c.res.status = 400;
|
|
72
60
|
|
|
73
|
-
return await returnHTMLResponse(c, () =>
|
|
74
|
-
|
|
61
|
+
return await returnHTMLResponse(c, () => {
|
|
62
|
+
return _errorHandler ? _errorHandler(c, { data, error }) : api.render(c, { data, error });
|
|
63
|
+
}, { status: 400 });
|
|
64
|
+
});
|
|
75
65
|
|
|
76
66
|
// Set the name of the action for the route
|
|
77
67
|
route._config.name = name;
|
package/src/middleware.ts
CHANGED
|
@@ -40,14 +40,14 @@ export function validateQuery(schema: ZodObject | ZodAny): HS.MiddlewareFunction
|
|
|
40
40
|
const query = formDataToJSON(context.req.query);
|
|
41
41
|
const validated = schema.safeParse(query);
|
|
42
42
|
|
|
43
|
+
// Store the validated query in the context variables
|
|
44
|
+
context.vars.query = validated.data as z.infer<typeof schema>;
|
|
45
|
+
|
|
43
46
|
if (!validated.success) {
|
|
44
47
|
const err = formatZodError(validated.error);
|
|
45
48
|
return context.res.error(err, { status: 400 });
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
// Store the validated query in the context variables
|
|
49
|
-
context.vars.query = validated.data as z.infer<typeof schema>;
|
|
50
|
-
|
|
51
51
|
return next();
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -67,6 +67,8 @@ export function validateBody(schema: ZodObject | ZodAny, type?: TValidationType)
|
|
|
67
67
|
const urlencoded = await context.req.urlencoded();
|
|
68
68
|
body = formDataToJSON(urlencoded);
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
context.vars.body = body as z.infer<typeof schema>;
|
|
70
72
|
const validated = schema.safeParse(body);
|
|
71
73
|
|
|
72
74
|
if (!validated.success) {
|
|
@@ -75,9 +77,6 @@ export function validateBody(schema: ZodObject | ZodAny, type?: TValidationType)
|
|
|
75
77
|
//return context.res.error(err, { status: 400 });
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
// Store the validated body in the context variables
|
|
79
|
-
context.vars.body = validated.data as z.infer<typeof schema>;
|
|
80
|
-
|
|
81
80
|
return next();
|
|
82
81
|
}
|
|
83
82
|
}
|
package/src/server.ts
CHANGED
|
@@ -61,6 +61,9 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
61
61
|
delete params[catchAllParam];
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// Status override for the response. Will use if set. (e.g. c.res.status = 400)
|
|
65
|
+
let status: number | undefined = undefined;
|
|
66
|
+
|
|
64
67
|
const merge = (response: Response) => {
|
|
65
68
|
// Convert headers to plain objects and merge (response headers override context headers)
|
|
66
69
|
const mergedHeaders = {
|
|
@@ -69,12 +72,12 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
return new Response(response.body, {
|
|
72
|
-
status: response.status,
|
|
75
|
+
status: context.res.status ?? response.status,
|
|
73
76
|
headers: mergedHeaders,
|
|
74
77
|
});
|
|
75
78
|
};
|
|
76
79
|
|
|
77
|
-
|
|
80
|
+
const context: HS.Context = {
|
|
78
81
|
vars: {},
|
|
79
82
|
route: {
|
|
80
83
|
name: route?._config.name || undefined,
|
|
@@ -89,15 +92,15 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
89
92
|
headers,
|
|
90
93
|
query,
|
|
91
94
|
cookies: new Cookies(req),
|
|
92
|
-
async text() { return req.text() },
|
|
93
|
-
async json<T = unknown>() { return await req.json() as T },
|
|
94
|
-
async formData<T = unknown>() { return await req.formData() as T },
|
|
95
|
-
async urlencoded() { return new URLSearchParams(await req.text()) },
|
|
95
|
+
async text() { return req.clone().text() },
|
|
96
|
+
async json<T = unknown>() { return await req.clone().json() as T },
|
|
97
|
+
async formData<T = unknown>() { return await req.clone().formData() as T },
|
|
98
|
+
async urlencoded() { return new URLSearchParams(await req.clone().text()) },
|
|
96
99
|
},
|
|
97
100
|
res: {
|
|
98
101
|
cookies: new Cookies(req, headers),
|
|
99
102
|
headers,
|
|
100
|
-
|
|
103
|
+
status,
|
|
101
104
|
html: (html: string, options?: ResponseInit) => merge(new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } })),
|
|
102
105
|
json: (json: any, options?: ResponseInit) => merge(new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } })),
|
|
103
106
|
text: (text: string, options?: ResponseInit) => merge(new Response(text, { ...options, headers: { 'Content-Type': 'text/plain; charset=UTF-8', ...options?.headers } })),
|
|
@@ -107,6 +110,8 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
107
110
|
merge,
|
|
108
111
|
},
|
|
109
112
|
};
|
|
113
|
+
|
|
114
|
+
return context;
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
|
package/src/types.ts
CHANGED
|
@@ -68,6 +68,7 @@ export namespace Hyperspan {
|
|
|
68
68
|
export type HSResponse = {
|
|
69
69
|
cookies: Hyperspan.Cookies;
|
|
70
70
|
headers: Headers; // Headers to merge with final outgoing response
|
|
71
|
+
status: number | undefined;
|
|
71
72
|
html: (html: string, options?: ResponseInit) => Response
|
|
72
73
|
json: (json: any, options?: ResponseInit) => Response;
|
|
73
74
|
text: (text: string, options?: ResponseInit) => Response;
|
|
@@ -75,7 +76,6 @@ export namespace Hyperspan {
|
|
|
75
76
|
error: (error: Error, options?: ResponseInit) => Response;
|
|
76
77
|
notFound: (options?: ResponseInit) => Response;
|
|
77
78
|
merge: (response: Response) => Response;
|
|
78
|
-
raw: Response;
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
export interface Context {
|
package/src/client/js.test.ts
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
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
|
-
});
|