Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 76 additions & 16 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ import type {
FetchableObject,
HTTPHandler,
} from "./types/handler.ts";
import type { StandardSchemaV1, InferOutput } from "./utils/internal/standard-schema.ts";
import type { TypedRequest } from "fetchdts";
import type { InferOutput, StandardSchemaV1 } from "./utils/internal/standard-schema.ts";
import { NoHandler, type H3Core } from "./h3.ts";
import { validatedRequest, validatedURL, type OnValidateError } from "./utils/internal/validate.ts";
import {
validatedRequest,
validatedURL,
syncValidate,
validateResponse,
type OnValidateError,
} from "./utils/internal/validate.ts";

// --- event handler ---

Expand Down Expand Up @@ -61,39 +66,94 @@ type StringHeaders<T> = {
/**
* @experimental defineValidatedHandler is an experimental feature and API may change.
*/
// Helper type to create a validated H3Event with typed context.params
// After validation, params will have the inferred type from the schema
// Note: params remains optional for TypeScript compatibility, but is guaranteed at runtime
type ValidatedH3Event<RequestT extends EventHandlerRequest, Params> = Omit<
H3Event<RequestT>,
"context"
> & {
context: Omit<H3Event["context"], "params"> & {
params?: Params; // Typed from schema (optional for TS, guaranteed after validation)
};
};

export function defineValidatedHandler<
RequestBody extends StandardSchemaV1,
RequestHeaders extends StandardSchemaV1,
RequestQuery extends StandardSchemaV1,
Res extends EventHandlerResponse = EventHandlerResponse,
RouteParams extends StandardSchemaV1 = StandardSchemaV1<Record<string, string>>,
ResponseBody extends StandardSchemaV1 = StandardSchemaV1<any>,
>(
def: Omit<EventHandlerObject, "handler"> & {
validate?: {
body?: RequestBody;
headers?: RequestHeaders;
query?: RequestQuery;
params?: RouteParams;
response?: ResponseBody;
onError?: OnValidateError;
};
handler: EventHandler<
{
body: InferOutput<RequestBody>;
query: StringHeaders<InferOutput<RequestQuery>>;
},
Res
>;
handler: (
event: ValidatedH3Event<
EventHandlerRequest & {
body: InferOutput<RequestBody>;
query: StringHeaders<InferOutput<RequestQuery>>;
routerParams: InferOutput<RouteParams>;
},
InferOutput<RouteParams>
>,
) => InferOutput<ResponseBody> | Promise<InferOutput<ResponseBody>>;
},
): EventHandlerWithFetch<TypedRequest<InferOutput<RequestBody>, InferOutput<RequestHeaders>>, Res> {
): EventHandlerWithFetch<EventHandlerRequest, InferOutput<ResponseBody>> {
if (!def.validate) {
return defineHandler(def) as any;
return defineHandler(def) as EventHandlerWithFetch<
EventHandlerRequest,
InferOutput<ResponseBody>
>;
}
return defineHandler({
...def,
handler: function _validatedHandler(event) {
handler: async function _validatedHandler(event) {
// Validate route params
if (def.validate!.params) {
const params = event.context.params || {};
event.context.params = syncValidate(
"params",
params,
def.validate!.params,
def.validate!.onError,
) as Record<string, string>;
}

// Validate request and URL
(event as any) /* readonly */.req = validatedRequest(event.req, def.validate!);
(event as any) /* readonly */.url = validatedURL(event.url, def.validate!);
return def.handler(event as any);

// Execute handler - context.params is validated at this point
const result = await def.handler(
event as ValidatedH3Event<
EventHandlerRequest & {
body: InferOutput<RequestBody>;
query: StringHeaders<InferOutput<RequestQuery>>;
routerParams: InferOutput<RouteParams>;
},
InferOutput<RouteParams>
>,
);

// Validate response
if (def.validate!.response) {
return await validateResponse(
result,
def.validate!.response,
def.validate!.onError as OnValidateError<"response"> | undefined,
);
}

return result;
},
}) as any;
}) as EventHandlerWithFetch<EventHandlerRequest, InferOutput<ResponseBody>>;
}

// --- handler .fetch ---
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export {

// Route

export { type RouteDefinition, defineRoute } from "./utils/route.ts";
export { type RouteDefinition, type RouteValidation, defineRoute } from "./utils/route.ts";

// Request

Expand Down
32 changes: 31 additions & 1 deletion src/utils/internal/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export function validatedURL(
return url;
}

function syncValidate<Source extends "headers" | "query", T = unknown>(
export function syncValidate<Source extends string, T = unknown>(
source: Source,
data: unknown,
fn: StandardSchemaV1<T>,
Expand Down Expand Up @@ -199,3 +199,33 @@ function createValidationError(cause: Error | HTTPError | ErrorDetails | Failure
},
});
}

/**
* Validates a response value against a schema.
* Response validation errors use 500 status (server error) instead of 400.
*/
export async function validateResponse<Schema extends StandardSchemaV1>(
value: unknown,
schema: Schema,
onError?: OnValidateError<"response">,
): Promise<InferOutput<Schema>> {
try {
return await validateData(value, schema, {
onError: onError ? (result) => onError({ ...result, _source: "response" }) : undefined,
});
} catch (error: any) {
throw new HTTPError({
cause: error,
status: 500,
statusText:
error?.statusText && error.statusText !== VALIDATION_FAILED
? error.statusText
: "Response validation failed",
message:
error?.message && error.message !== VALIDATION_FAILED
? error.message
: "Response validation failed",
data: error?.data,
Comment on lines +217 to +228
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't return validator internals from 500 responses.

Response-schema mismatches are server bugs, not client input errors. Forwarding error?.message and error?.data here exposes internal field names and validation rules in a public 500 payload without giving the caller anything actionable.

Suggested fix
   } catch (error: any) {
     throw new HTTPError({
       cause: error,
       status: 500,
       statusText: "Response validation failed",
-      message: error?.message || "Response validation failed",
-      data: error?.data,
+      message: "Response validation failed",
     });
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
throw new HTTPError({
cause: error,
status: 500,
statusText: "Response validation failed",
message: error?.message || "Response validation failed",
data: error?.data,
throw new HTTPError({
cause: error,
status: 500,
statusText: "Response validation failed",
message: "Response validation failed",
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/internal/validate.ts` around lines 214 - 219, The thrown 500
includes validator internals (error?.message and error?.data) which leaks
server-side schema details; instead, in the throw new HTTPError call remove
error?.message and error?.data and replace them with a generic
statusText/message like "Response validation failed" (keep status: 500) and log
the original validator error privately (e.g., using the existing logger) before
throwing; update the throw site that constructs the HTTPError (the throw new
HTTPError({...}) block and any surrounding validate/response-validation
function) to avoid returning validator internals to clients while preserving
internal logging for diagnostics.

});
}
}
147 changes: 130 additions & 17 deletions src/utils/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,57 @@
import type { H3RouteMeta, HTTPMethod } from "../types/h3.ts";
import type { EventHandler, Middleware } from "../types/handler.ts";
import type { EventHandlerRequest, Middleware } from "../types/handler.ts";
import type { H3Plugin, H3 } from "../types/h3.ts";
import type { StandardSchemaV1 } from "./internal/standard-schema.ts";
import type { H3Event } from "../event.ts";
import type { StandardSchemaV1, InferOutput } from "./internal/standard-schema.ts";
import type { OnValidateError } from "./internal/validate.ts";
import { defineValidatedHandler } from "../handler.ts";

type StringHeaders<T> = {
[K in keyof T]: Extract<T[K], string>;
};

/**
* Route validation schemas
*/
export interface RouteValidation {
body?: StandardSchemaV1;
headers?: StandardSchemaV1;
query?: StandardSchemaV1;
params?: StandardSchemaV1;
response?: StandardSchemaV1;
onError?: OnValidateError;
}

type RouteValidationConfig<V extends RouteValidation> = Omit<V, "onError"> & {
onError?: OnValidateError;
};

type RouteEventRequest<V extends RouteValidation> = EventHandlerRequest & {
body: NonNullable<V["body"]> extends StandardSchemaV1
? InferOutput<NonNullable<V["body"]>>
: unknown;
query: NonNullable<V["query"]> extends StandardSchemaV1
? StringHeaders<InferOutput<NonNullable<V["query"]>>>
: Partial<Record<string, string>>;
routerParams: NonNullable<V["params"]> extends StandardSchemaV1
? InferOutput<NonNullable<V["params"]>>
: Record<string, string>;
};

type RouteEventParams<V extends RouteValidation> =
NonNullable<V["params"]> extends StandardSchemaV1
? InferOutput<NonNullable<V["params"]>>
: Record<string, string>;

type RouteResponse<V extends RouteValidation> =
NonNullable<V["response"]> extends StandardSchemaV1
? InferOutput<NonNullable<V["response"]>>
: unknown;

/**
* Route definition options
* Route definition options with type-safe validation
*/
export interface RouteDefinition {
export interface RouteDefinition<V extends RouteValidation = RouteValidation> {
/**
* HTTP method for the route, e.g. 'GET', 'POST', etc.
*/
Expand All @@ -21,7 +65,9 @@ export interface RouteDefinition {
/**
* Handler function for the route.
*/
handler: EventHandler;
handler: (
event: ValidatedRouteEvent<RouteEventRequest<V>, RouteEventParams<V>>,
) => RouteResponse<V> | Promise<RouteResponse<V>>;

/**
* Optional middleware to run before the handler.
Expand All @@ -33,38 +79,105 @@ export interface RouteDefinition {
*/
meta?: H3RouteMeta;

// Validation schemas
// TODO: Support generics for better typing `handler` input
validate?: {
body?: StandardSchemaV1;
headers?: StandardSchemaV1;
query?: StandardSchemaV1;
};
/**
* Validation schemas for request and response
*/
validate?: RouteValidationConfig<V>;
}

// Helper type for validated H3Event with typed context.params
type ValidatedRouteEvent<RequestT extends EventHandlerRequest, ParamsT> = Omit<
H3Event<RequestT>,
"context"
> & {
context: Omit<H3Event["context"], "params"> & {
params?: ParamsT;
};
};

// Overload: With validation
export function defineRoute<
Body extends StandardSchemaV1 = never,
Headers extends StandardSchemaV1 = never,
Query extends StandardSchemaV1 = never,
Params extends StandardSchemaV1 = never,
Response extends StandardSchemaV1 = never,
>(def: {
method: HTTPMethod;
route: string;
validate: {
body?: Body;
headers?: Headers;
query?: Query;
params?: Params;
response?: Response;
onError?: OnValidateError;
};
handler: (
event: ValidatedRouteEvent<
EventHandlerRequest & {
body: [Body] extends [never] ? unknown : InferOutput<Body>;
query: [Query] extends [never]
? Partial<Record<string, string>>
: StringHeaders<InferOutput<Query>>;
routerParams: [Params] extends [never] ? Record<string, string> : InferOutput<Params>;
},
[Params] extends [never] ? Record<string, string> : InferOutput<Params>
>,
) =>
| ([Response] extends [never] ? unknown : InferOutput<Response>)
| Promise<[Response] extends [never] ? unknown : InferOutput<Response>>;
middleware?: Middleware[];
meta?: H3RouteMeta;
}): H3Plugin;

// Overload: Without validation
export function defineRoute(def: {
method: HTTPMethod;
route: string;
handler: (event: H3Event) => unknown | Promise<unknown>;
middleware?: Middleware[];
meta?: H3RouteMeta;
validate?: never;
}): H3Plugin;

/**
* Define a route as a plugin that can be registered with app.register()
*
* Routes defined with this function automatically get type-safe validation
* for params, query, body, and response based on the provided schemas.
*
* @example
* ```js
* import { z } from "zod";
*
* const userRoute = defineRoute({
* method: 'POST',
* route: '/api/users/:id',
* validate: {
* query: z.object({ id: z.string().uuid() }),
* params: z.object({ id: z.string().uuid() }),
* query: z.object({ include: z.string().optional() }),
* body: z.object({ name: z.string() }),
* response: z.object({ id: z.string(), name: z.string() }),
* },
* handler: (event) => {
* return { success: true };
* // event.context.params, await event.req.json(), and return value are all typed!
* const { id } = event.context.params;
* const body = await event.req.json();
* return { id, name: body.name };
* }
* });
*
* app.register(userRoute);
* app.use(userRoute);
* ```
Comment on lines 150 to 172
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "route.ts" -type f | head -20

Repository: h3js/h3

Length of output: 71


🏁 Script executed:

cat -n src/utils/route.ts | head -200

Repository: h3js/h3

Length of output: 6883


Fix the example code before publishing it.

The example has two issues:

  1. The handler on line 163 uses await without async — this is a syntax error
  2. Line 171 uses app.use(userRoute) but the API documentation states the route should be registered with app.register() (see line 145)
Suggested fix
- *    handler: (event) => {
+ *    handler: async (event) => {
 *      // event.context.params, await event.req.json(), and return value are all typed!
 *      const { id } = event.context.params;
 *      const body = await event.req.json();
 *      return { id, name: body.name };
 *    }
 * });
 *
- * app.use(userRoute);
+ * app.register(userRoute);
 * ```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* @example
* ```js
* import { z } from "zod";
*
* const userRoute = defineRoute({
* method: 'POST',
* route: '/api/users/:id',
* validate: {
* query: z.object({ id: z.string().uuid() }),
* params: z.object({ id: z.string().uuid() }),
* query: z.object({ include: z.string().optional() }),
* body: z.object({ name: z.string() }),
* response: z.object({ id: z.string(), name: z.string() }),
* },
* handler: (event) => {
* return { success: true };
* // event.context.params, await event.req.json(), and return value are all typed!
* const { id } = event.context.params;
* const body = await event.req.json();
* return { id, name: body.name };
* }
* });
*
* app.register(userRoute);
* app.use(userRoute);
* ```
* `@example`
*
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/route.ts` around lines 133 - 155, The example's handler is using
await without being async and the route registration uses app.use instead of
app.register; update the example so the handler function passed to defineRoute
is declared async (e.g., async (event) => { ... }) so await is valid, and
replace the app.use(userRoute) call with app.register(userRoute) to match the
documented registration API; ensure the symbol names defineRoute, handler arrow
function, and userRoute are adjusted accordingly in the snippet.

*/
export function defineRoute(def: RouteDefinition): H3Plugin {
const handler = defineValidatedHandler(def) as any;
export function defineRoute<V extends RouteValidation>(def: RouteDefinition<V>): H3Plugin {
// TypeScript cannot infer complex conditional types between RouteDefinition and
// defineValidatedHandler parameters. Runtime types are identical and safe.
type ValidatedHandlerParam = Parameters<typeof defineValidatedHandler>[0];

const handler = defineValidatedHandler(def as unknown as ValidatedHandlerParam);

return (h3: H3) => {
h3.on(def.method, def.route, handler);
};
Expand Down
Loading
Loading