@hyperspan/framework 1.0.1 → 1.0.3
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 -1
- package/src/actions.test.ts +68 -1
- package/src/actions.ts +12 -5
- package/src/server.test.ts +6 -6
- package/src/server.ts +2 -2
- package/src/types.ts +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspan/framework",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Hyperspan Web Framework",
|
|
5
5
|
"main": "src/server.ts",
|
|
6
6
|
"types": "src/server.ts",
|
|
@@ -67,12 +67,14 @@
|
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@types/bun": "^1.3.1",
|
|
70
|
+
"@types/debug": "^4.1.12",
|
|
70
71
|
"@types/node": "^24.10.0",
|
|
71
72
|
"prettier": "^3.5.2",
|
|
72
73
|
"typescript": "^5.9.3"
|
|
73
74
|
},
|
|
74
75
|
"dependencies": {
|
|
75
76
|
"@hyperspan/html": "^1.0.0",
|
|
77
|
+
"debug": "^4.4.3",
|
|
76
78
|
"isbot": "^5.1.32",
|
|
77
79
|
"zod": "^4.1.12"
|
|
78
80
|
}
|
package/src/actions.test.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { test, expect, describe } from 'bun:test';
|
|
|
2
2
|
import { createAction } from './actions';
|
|
3
3
|
import { html, render, type HSHtml } from '@hyperspan/html';
|
|
4
4
|
import { createContext } from './server';
|
|
5
|
-
import type { Hyperspan as HS } from './types';
|
|
6
5
|
import * as z from 'zod/v4';
|
|
7
6
|
|
|
8
7
|
describe('createAction', () => {
|
|
@@ -40,6 +39,74 @@ describe('createAction', () => {
|
|
|
40
39
|
expect(htmlString).toContain('name="name"');
|
|
41
40
|
});
|
|
42
41
|
|
|
42
|
+
test('creates an action with a simple form and no schema that returns HTML on POST', async () => {
|
|
43
|
+
const action = createAction({
|
|
44
|
+
name: 'test',
|
|
45
|
+
}).form((c) => {
|
|
46
|
+
return html`
|
|
47
|
+
<form>
|
|
48
|
+
<input type="text" name="name" />
|
|
49
|
+
<button type="submit">Submit</button>
|
|
50
|
+
</form>
|
|
51
|
+
`;
|
|
52
|
+
}).post(async (c, { data }) => {
|
|
53
|
+
return c.res.html(`
|
|
54
|
+
<p>Hello, ${data?.name}!</p>
|
|
55
|
+
`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Build form data
|
|
59
|
+
const formData = new FormData();
|
|
60
|
+
formData.append('name', 'John Doe');
|
|
61
|
+
|
|
62
|
+
// Test render method
|
|
63
|
+
const request = new Request(`http://localhost:3000${action._path()}`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
body: formData,
|
|
66
|
+
});
|
|
67
|
+
const response = await action.fetch(request);
|
|
68
|
+
expect(response).toBeInstanceOf(Response);
|
|
69
|
+
expect(response.status).toBe(200);
|
|
70
|
+
const responseText = await response.text();
|
|
71
|
+
expect(responseText).toContain('<p>Hello, John Doe!</p>');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('errors thrown on POST handler provided by user are caught and rendered', async () => {
|
|
75
|
+
const action = createAction({
|
|
76
|
+
name: 'test',
|
|
77
|
+
}).form((c, { error }) => {
|
|
78
|
+
if (error) {
|
|
79
|
+
return html`
|
|
80
|
+
<p>Error: ${error.message}</p>
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return html`
|
|
85
|
+
<form>
|
|
86
|
+
<input type="text" name="name" />
|
|
87
|
+
<button type="submit">Submit</button>
|
|
88
|
+
</form>
|
|
89
|
+
`;
|
|
90
|
+
}).post(async (c, { data }) => {
|
|
91
|
+
throw new Error('Test error');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Build form data
|
|
95
|
+
const formData = new FormData();
|
|
96
|
+
formData.append('name', 'John Doe');
|
|
97
|
+
|
|
98
|
+
// Test render method
|
|
99
|
+
const request = new Request(`http://localhost:3000${action._path()}`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: formData,
|
|
102
|
+
});
|
|
103
|
+
const response = await action.fetch(request);
|
|
104
|
+
expect(response).toBeInstanceOf(Response);
|
|
105
|
+
expect(response.status).toBe(500);
|
|
106
|
+
const responseText = await response.text();
|
|
107
|
+
expect(responseText).toContain('<p>Error: Test error</p>');
|
|
108
|
+
});
|
|
109
|
+
|
|
43
110
|
test('creates an action with a Zod schema matching form inputs', async () => {
|
|
44
111
|
const schema = z.object({
|
|
45
112
|
name: z.string().min(1, 'Name is required'),
|
package/src/actions.ts
CHANGED
|
@@ -5,7 +5,9 @@ import type { Hyperspan as HS } from './types';
|
|
|
5
5
|
import { assetHash, formDataToJSON } from './utils';
|
|
6
6
|
import { buildClientJS } from './client/js';
|
|
7
7
|
import { validateBody, ZodValidationError } from './middleware';
|
|
8
|
+
import { debug } from 'debug';
|
|
8
9
|
|
|
10
|
+
const log = debug('hyperspan:actions');
|
|
9
11
|
const actionsClientJS = await buildClientJS(import.meta.resolve('./client/_hs/hyperspan-actions.client'));
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -35,7 +37,10 @@ export function createAction<T extends z.ZodObject<any, any>>(params: { name: st
|
|
|
35
37
|
throw new Error('Action POST handler not set! Every action must have a POST handler.');
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
const
|
|
40
|
+
const data = c.vars.body as z.infer<T> || formDataToJSON(await c.req.formData()) || {};
|
|
41
|
+
log('POST handler', { data });
|
|
42
|
+
const response = await _handler(c, { data });
|
|
43
|
+
log('POST handler response', { response });
|
|
39
44
|
|
|
40
45
|
if (response instanceof Response) {
|
|
41
46
|
// Replace redirects with special header because fetch() automatically follows redirects
|
|
@@ -52,11 +57,13 @@ export function createAction<T extends z.ZodObject<any, any>>(params: { name: st
|
|
|
52
57
|
* Custom error handler for the action since validateBody() throws a HTTPResponseException
|
|
53
58
|
*/
|
|
54
59
|
.errorHandler(async (c: HS.Context, err: HTTPResponseException) => {
|
|
55
|
-
const data = c.vars.body as
|
|
56
|
-
const error = err._error as ZodValidationError;
|
|
60
|
+
const data = c.vars.body as z.infer<T> || formDataToJSON(await c.req.formData()) || {};
|
|
61
|
+
const error = err._error as ZodValidationError || err;
|
|
57
62
|
|
|
58
|
-
// Set the status to 400 by
|
|
59
|
-
c.res.status = 400;
|
|
63
|
+
// Set the status to 400 if it's a ZodValidationError, otherwise 500 (Error thrown by user POST handler)
|
|
64
|
+
c.res.status = err._error ? 400 : 500;
|
|
65
|
+
|
|
66
|
+
log('errorHandler', { data, error });
|
|
60
67
|
|
|
61
68
|
return await returnHTMLResponse(c, () => {
|
|
62
69
|
return _errorHandler ? _errorHandler(c, { data, error }) : api.render(c, { data, error });
|
package/src/server.test.ts
CHANGED
|
@@ -144,7 +144,7 @@ test('createContext() can get and set cookies', () => {
|
|
|
144
144
|
}
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
test('createContext() merge() function preserves custom headers when using response methods', () => {
|
|
147
|
+
test('createContext() merge() function preserves custom headers when using response methods', async () => {
|
|
148
148
|
// Create a request
|
|
149
149
|
const request = new Request('http://localhost:3000/');
|
|
150
150
|
|
|
@@ -157,7 +157,7 @@ test('createContext() merge() function preserves custom headers when using respo
|
|
|
157
157
|
context.res.headers.set('Authorization', 'Bearer token123');
|
|
158
158
|
|
|
159
159
|
// Use html() method which should merge headers
|
|
160
|
-
const response = context.res.html('<h1>Test</h1>');
|
|
160
|
+
const response = await context.res.html('<h1>Test</h1>');
|
|
161
161
|
|
|
162
162
|
// Verify the response has both the custom headers and the Content-Type header
|
|
163
163
|
expect(response.headers.get('X-Custom-Header')).toBe('custom-value');
|
|
@@ -169,7 +169,7 @@ test('createContext() merge() function preserves custom headers when using respo
|
|
|
169
169
|
expect(response.status).toBe(200);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
test('createContext() merge() function preserves custom headers with json() method', () => {
|
|
172
|
+
test('createContext() merge() function preserves custom headers with json() method', async () => {
|
|
173
173
|
const request = new Request('http://localhost:3000/');
|
|
174
174
|
const context = createContext(request);
|
|
175
175
|
|
|
@@ -178,7 +178,7 @@ test('createContext() merge() function preserves custom headers with json() meth
|
|
|
178
178
|
context.res.headers.set('X-Request-ID', 'req-123');
|
|
179
179
|
|
|
180
180
|
// Use json() method
|
|
181
|
-
const response = context.res.json({ message: 'Hello' });
|
|
181
|
+
const response = await context.res.json({ message: 'Hello' });
|
|
182
182
|
|
|
183
183
|
// Verify headers are merged
|
|
184
184
|
expect(response.headers.get('X-API-Version')).toBe('v1');
|
|
@@ -186,7 +186,7 @@ test('createContext() merge() function preserves custom headers with json() meth
|
|
|
186
186
|
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test('createContext() merge() function allows response headers to override context headers', () => {
|
|
189
|
+
test('createContext() merge() function allows response headers to override context headers', async () => {
|
|
190
190
|
const request = new Request('http://localhost:3000/');
|
|
191
191
|
const context = createContext(request);
|
|
192
192
|
|
|
@@ -194,7 +194,7 @@ test('createContext() merge() function allows response headers to override conte
|
|
|
194
194
|
context.res.headers.set('X-Header', 'context-value');
|
|
195
195
|
|
|
196
196
|
// Use html() with options that include the same header (should override)
|
|
197
|
-
const response = context.res.html('<h1>Test</h1>', {
|
|
197
|
+
const response = await context.res.html('<h1>Test</h1>', {
|
|
198
198
|
headers: {
|
|
199
199
|
'X-Header': 'response-value',
|
|
200
200
|
},
|
package/src/server.ts
CHANGED
|
@@ -64,14 +64,14 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
|
|
|
64
64
|
// Status override for the response. Will use if set. (e.g. c.res.status = 400)
|
|
65
65
|
let status: number | undefined = undefined;
|
|
66
66
|
|
|
67
|
-
const merge = (response: Response) => {
|
|
67
|
+
const merge = async (response: Response) => {
|
|
68
68
|
// Convert headers to plain objects and merge (response headers override context headers)
|
|
69
69
|
const mergedHeaders = {
|
|
70
70
|
...Object.fromEntries(headers.entries()),
|
|
71
71
|
...Object.fromEntries(response.headers.entries()),
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
-
return new Response(response.
|
|
74
|
+
return new Response(await response.text(), {
|
|
75
75
|
status: context.res.status ?? response.status,
|
|
76
76
|
headers: mergedHeaders,
|
|
77
77
|
});
|
package/src/types.ts
CHANGED
|
@@ -69,13 +69,13 @@ export namespace Hyperspan {
|
|
|
69
69
|
cookies: Hyperspan.Cookies;
|
|
70
70
|
headers: Headers; // Headers to merge with final outgoing response
|
|
71
71
|
status: number | undefined;
|
|
72
|
-
html: (html: string, options?: ResponseInit) => Response
|
|
73
|
-
json: (json: any, options?: ResponseInit) => Response
|
|
74
|
-
text: (text: string, options?: ResponseInit) => Response
|
|
75
|
-
redirect: (url: string, options?: ResponseInit) => Response
|
|
76
|
-
error: (error: Error, options?: ResponseInit) => Response
|
|
77
|
-
notFound: (options?: ResponseInit) => Response
|
|
78
|
-
merge: (response: Response) => Response
|
|
72
|
+
html: (html: string, options?: ResponseInit) => Promise<Response>;
|
|
73
|
+
json: (json: any, options?: ResponseInit) => Promise<Response>;
|
|
74
|
+
text: (text: string, options?: ResponseInit) => Promise<Response>;
|
|
75
|
+
redirect: (url: string, options?: ResponseInit) => Promise<Response>;
|
|
76
|
+
error: (error: Error, options?: ResponseInit) => Promise<Response>;
|
|
77
|
+
notFound: (options?: ResponseInit) => Promise<Response>;
|
|
78
|
+
merge: (response: Response) => Promise<Response>;
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
export interface Context {
|