@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -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._route}`, {
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
- // Parse form data
35
- const formData = await c.req.formData();
36
- const jsonData = formDataToJSON(formData) as Partial<z.infer<T>>;
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
- const response = await _handler(c, { data });
38
+ const response = await _handler(c, { data: c.vars.body });
51
39
 
52
- if (response instanceof Response) {
53
- // Replace redirects with special header because fetch() automatically follows redirects
54
- // and we want to redirect the user to the actual full page instead
55
- if ([301, 302, 307, 308].includes(response.status)) {
56
- response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
57
- response.headers.delete('Location');
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
- if (error && _errorHandler) {
67
- const errorHandler = _errorHandler; // Required for TypeScript to infer the correct type after narrowing
68
- return await returnHTMLResponse(c, () => errorHandler(c, { data, error }), {
69
- status: 400,
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, () => api.render(c, { data, error }), { status: 400 });
74
- }, { middleware: schema ? [validateBody(schema)] : [] });
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
- return {
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
- raw: new Response(),
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 {
@@ -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
- });