Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
86 changes: 70 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,88 @@ 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) as Record<
string,
string
>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// 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);
}

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
23 changes: 22 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,24 @@ 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,
): Promise<InferOutput<Schema>> {
try {
return await validateData(value, schema);
} catch (error: any) {
throw new HTTPError({
cause: error,
status: 500,
statusText: "Response validation failed",
message: 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.

});
}
}
130 changes: 113 additions & 17 deletions src/utils/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
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 { 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;
}

/**
* 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 +37,21 @@ export interface RouteDefinition {
/**
* Handler function for the route.
*/
handler: EventHandler;
handler: (
event: H3Event<
EventHandlerRequest & {
body: V["body"] extends StandardSchemaV1 ? InferOutput<V["body"]> : unknown;
query: V["query"] extends StandardSchemaV1
? StringHeaders<InferOutput<V["query"]>>
: Partial<Record<string, string>>;
routerParams: V["params"] extends StandardSchemaV1
? InferOutput<V["params"]>
: Record<string, string>;
}
>,
) =>
| (V["response"] extends StandardSchemaV1 ? InferOutput<V["response"]> : unknown)
| Promise<V["response"] extends StandardSchemaV1 ? InferOutput<V["response"]> : unknown>;

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:

cd /repo && find . -type f -name "route.ts" -o -name "index.ts" | head -20

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

cd /repo && wc -l src/utils/route.ts src/index.ts

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

cd /repo && cat -n src/utils/route.ts | head -160

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

cd /repo && cat -n src/index.ts

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

find . -type f -name "route.ts" -o -name "index.ts" 2>/dev/null | grep -E "(route\.ts|index\.ts)$"

Repository: h3js/h3

Length of output: 86


🏁 Script executed:

wc -l src/utils/route.ts src/index.ts 2>/dev/null || echo "Files not found, listing directory structure..."

Repository: h3js/h3

Length of output: 103


🏁 Script executed:

ls -la src/utils/ && ls -la src/ 2>/dev/null || find . -name "route.ts" -type f

Repository: h3js/h3

Length of output: 2279


🏁 Script executed:

cat -n src/utils/route.ts

Repository: h3js/h3

Length of output: 6883


🏁 Script executed:

cat -n src/index.ts

Repository: h3js/h3

Length of output: 5568


Documentation example handler must be declared async to use await.

The example at lines 163-168 uses await on line 166 but the handler function is not declared as async. Change handler: (event) => { to handler: async (event) => {.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/route.ts` around lines 40 - 54, The example route's handler that
uses await needs to be declared async: locate the example route object that
defines the handler property (the handler: (event) => { ... } example) and
change its declaration to handler: async (event) => { ... } so await inside the
function is valid; ensure the function body and returned types remain unchanged
(the change is only adding the async keyword to the handler).


/**
* Optional middleware to run before the handler.
Expand All @@ -33,38 +63,104 @@ 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?: 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 (any combination of validation schemas)
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;
};
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