@hyperspan/framework 1.0.5 → 1.0.7

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.5",
3
+ "version": "1.0.7",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -256,7 +256,7 @@ describe('createAction', () => {
256
256
 
257
257
  const responseText = await response.text();
258
258
  // Should render the custom error handler
259
- expect(responseText).toContain('Caught error in custom error handler: Input validation error(s)');
259
+ expect(responseText).toContain('Invalid email address');
260
260
  expect(responseText).toContain('Data: {"email":"not-an-email"}');
261
261
  // Should NOT contain the success message from post handler
262
262
  expect(responseText).not.toContain('Hello,');
package/src/actions.ts CHANGED
@@ -84,8 +84,8 @@ export function createAction<T extends z.ZodObject<any, any>>(params: { name: st
84
84
  * Form to render
85
85
  * This will be wrapped in a <hs-action> web component and submitted via fetch()
86
86
  */
87
- form(form: HS.ActionFormHandler<T>) {
88
- api._form = form;
87
+ form(_formFn) {
88
+ api._form = _formFn;
89
89
  return api;
90
90
  },
91
91
  /**
@@ -101,7 +101,7 @@ export function createAction<T extends z.ZodObject<any, any>>(params: { name: st
101
101
  /**
102
102
  * Get form renderer method
103
103
  */
104
- render(c: HS.Context, props?: HS.ActionProps<T>) {
104
+ render(c, props) {
105
105
  const formContent = api._form ? api._form(c, props || {}) : null;
106
106
  return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>${actionsClientJS.renderScriptTag()}` : null;
107
107
  },
@@ -109,6 +109,10 @@ export function createAction<T extends z.ZodObject<any, any>>(params: { name: st
109
109
  _errorHandler = handler;
110
110
  return api;
111
111
  },
112
+ use(middleware: HS.MiddlewareFunction) {
113
+ route.use(middleware);
114
+ return api;
115
+ },
112
116
  middleware(middleware: Array<HS.MiddlewareFunction>) {
113
117
  route.middleware(middleware);
114
118
  return api;
package/src/client/js.ts CHANGED
@@ -10,6 +10,7 @@ export const JS_PUBLIC_PATH = '/_hs/js';
10
10
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
11
11
  export const JS_IMPORT_MAP = new Map<string, string>();
12
12
  const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
13
+ const CLIENT_JS_BUILD_PROMISES = new Map<string, Promise<void>>();
13
14
  const EXPORT_REGEX = /export\{(.*)\}/g;
14
15
 
15
16
  /**
@@ -21,41 +22,57 @@ export async function buildClientJS(modulePathResolved: string): Promise<HS.Clie
21
22
 
22
23
  // Cache: Avoid re-processing the same file
23
24
  if (!CLIENT_JS_CACHE.has(assetHash)) {
24
- // Build the client JS module
25
- const result = await Bun.build({
26
- entrypoints: [modulePath],
27
- outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
28
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
29
- external: Array.from(JS_IMPORT_MAP.keys()),
30
- minify: true,
31
- format: 'esm',
32
- target: 'browser',
33
- env: 'APP_PUBLIC_*',
34
- });
25
+ const existingBuild = CLIENT_JS_BUILD_PROMISES.get(assetHash);
26
+ // Await the existing build promise if it exists (this can get called in parallel from Bun traversing imports)
27
+ if (existingBuild) {
28
+ await existingBuild;
29
+ } else {
30
+ const buildPromise = (async () => {
31
+ // Build the client JS module
32
+ const result = await Bun.build({
33
+ entrypoints: [modulePath],
34
+ outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
35
+ naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
36
+ external: Array.from(JS_IMPORT_MAP.keys()),
37
+ minify: true,
38
+ format: 'esm',
39
+ target: 'browser',
40
+ env: 'APP_PUBLIC_*',
41
+ });
35
42
 
36
- // Add output file to import map
37
- const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
38
- const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
39
- JS_IMPORT_MAP.set(esmName, publicPath);
43
+ // Add output file to import map
44
+ const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
45
+ const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
46
+ JS_IMPORT_MAP.set(esmName, publicPath);
40
47
 
41
- // Get the contents of the file to extract the exports
42
- const contents = await result.outputs[0].text();
43
- const exportLine = EXPORT_REGEX.exec(contents);
48
+ // Get the contents of the file to extract the exports
49
+ const contents = await result.outputs[0].text();
50
+ const exportLine = EXPORT_REGEX.exec(contents);
44
51
 
45
- let exports = '{}';
46
- if (exportLine) {
47
- const exportName = exportLine[1];
48
- exports =
49
- '{' +
50
- exportName
51
- .split(',')
52
- .map((name) => name.trim().split(' as '))
53
- .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
54
- .join(', ') +
55
- '}';
52
+ let exports = '{}';
53
+ if (exportLine) {
54
+ const exportName = exportLine[1];
55
+ exports =
56
+ '{' +
57
+ exportName
58
+ .split(',')
59
+ .map((name) => name.trim().split(' as '))
60
+ .map(([name, alias]) => `${alias === 'default' ? 'default as ' + name : alias}`)
61
+ .join(', ') +
62
+ '}';
63
+ }
64
+ const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
65
+
66
+ CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
67
+ })();
68
+
69
+ CLIENT_JS_BUILD_PROMISES.set(assetHash, buildPromise);
70
+ try {
71
+ await buildPromise;
72
+ } finally {
73
+ CLIENT_JS_BUILD_PROMISES.delete(assetHash);
74
+ }
56
75
  }
57
- const fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2');
58
- CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
59
76
  }
60
77
 
61
78
  const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
package/src/middleware.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { formDataToJSON } from './utils';
2
- import { z, flattenError } from 'zod/v4';
2
+ import { z, flattenError, prettifyError } from 'zod/v4';
3
3
 
4
4
  import type { ZodAny, ZodObject, ZodError } from 'zod/v4';
5
5
  import type { Hyperspan as HS } from './types';
@@ -26,12 +26,15 @@ function inferValidationType(headers: Headers): TValidationType {
26
26
  }
27
27
 
28
28
  export class ZodValidationError extends Error {
29
- constructor(flattened: ReturnType<typeof flattenError>) {
30
- super('Input validation error(s)');
29
+ public fieldErrors;
30
+ public formErrors: unknown[];
31
+ constructor(error: ZodError, schema: ZodObject | ZodAny) {
32
+ const message = prettifyError(error);
33
+ const flattened = flattenError<z.infer<typeof schema>>(error);
34
+ super(message);
31
35
  this.name = 'ZodValidationError';
32
-
33
- // Copy all properties from flattened error
34
- Object.assign(this, flattened);
36
+ this.fieldErrors = flattened.fieldErrors;
37
+ this.formErrors = flattened.formErrors;
35
38
  }
36
39
  }
37
40
 
@@ -44,8 +47,8 @@ export function validateQuery(schema: ZodObject | ZodAny): HS.MiddlewareFunction
44
47
  context.vars.query = validated.data as z.infer<typeof schema>;
45
48
 
46
49
  if (!validated.success) {
47
- const err = formatZodError(validated.error);
48
- return context.res.error(err, { status: 400 });
50
+ const err = new ZodValidationError(validated.error, schema);
51
+ throw new HTTPResponseException(err, { status: 400 });
49
52
  }
50
53
 
51
54
  return next();
@@ -72,20 +75,14 @@ export function validateBody(schema: ZodObject | ZodAny, type?: TValidationType)
72
75
  const validated = schema.safeParse(body);
73
76
 
74
77
  if (!validated.success) {
75
- const err = formatZodError(validated.error);
78
+ const err = new ZodValidationError(validated.error, schema);
76
79
  throw new HTTPResponseException(err, { status: 400 });
77
- //return context.res.error(err, { status: 400 });
78
80
  }
79
81
 
80
82
  return next();
81
83
  }
82
84
  }
83
85
 
84
- export function formatZodError(error: ZodError): ZodValidationError {
85
- const zodError = flattenError(error);
86
- return new ZodValidationError(zodError);
87
- }
88
-
89
86
  /**
90
87
  * Type guard to check if a handler is a middleware function
91
88
  * Middleware functions have 2 parameters (context, next)
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { HSHtml } from '@hyperspan/html';
2
+ import { ZodValidationError } from './middleware';
2
3
  import * as z from 'zod/v4';
3
4
 
4
5
  /**
@@ -156,20 +157,29 @@ export namespace Hyperspan {
156
157
  /**
157
158
  * Action = Form + route handler
158
159
  */
159
- export type ActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
160
- export type ActionProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: z.ZodError | Error };
160
+ // Form renderer
161
+ export type ActionFormResponse = HSHtml | void | null | Promise<HSHtml | void | null>;
162
+ export type ActionFormProps<T extends z.ZodTypeAny> = { data?: Partial<z.infer<T>>; error?: ZodValidationError };
163
+ export type ActionForm<T extends z.ZodTypeAny> = (
164
+ c: Context, props: ActionFormProps<T>
165
+ ) => ActionFormResponse;
166
+ // Form handler
167
+ export type ActionFormHandlerResponse = ActionFormResponse | Response | Promise<Response>;
168
+ export type ActionFormHandlerProps<T extends z.ZodTypeAny> = { data: z.infer<T>; error?: ZodValidationError | Error };
161
169
  export type ActionFormHandler<T extends z.ZodTypeAny> = (
162
- c: Context, props: ActionProps<T>
163
- ) => ActionResponse;
170
+ c: Context, props: ActionFormHandlerProps<T>
171
+ ) => ActionFormHandlerResponse;
172
+ // Action API
164
173
  export interface Action<T extends z.ZodTypeAny> {
165
174
  _kind: 'hsAction';
166
175
  _config: Partial<Hyperspan.RouteConfig>;
167
176
  _path(): string;
168
- _form: null | ActionFormHandler<T>;
169
- form(form: ActionFormHandler<T>): Action<T>;
170
- render: (c: Context, props?: ActionProps<T>) => ActionResponse;
177
+ _form: null | ActionForm<T>;
178
+ form(form: ActionForm<T>): Action<T>;
179
+ render: (c: Context, props?: ActionFormProps<T>) => ActionFormResponse;
171
180
  post: (handler: ActionFormHandler<T>) => Action<T>;
172
181
  errorHandler: (handler: ActionFormHandler<T>) => Action<T>;
182
+ use: (middleware: Hyperspan.MiddlewareFunction) => Action<T>;
173
183
  middleware: (middleware: Array<Hyperspan.MiddlewareFunction>) => Action<T>;
174
184
  fetch: (request: Request) => Promise<Response>;
175
185
  }