Skip to content
Draft
9 changes: 5 additions & 4 deletions src/event.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ServerRequest, ServerRuntimeContext } from "srvx";
import type { ServerRequest, ServerRequestContext, ServerRuntimeContext } from "srvx";
import type { H3EventContext } from "./types/context.ts";

import { EmptyObject } from "./utils/internal/obj.ts";
Expand Down Expand Up @@ -29,6 +29,7 @@ export interface HTTPEvent<_RequestT extends EventHandlerRequest = EventHandlerR

export class H3Event<
_RequestT extends EventHandlerRequest = EventHandlerRequest,
_ContextT extends ServerRequestContext = H3EventContext,
> implements HTTPEvent<_RequestT> {
/**
* Access to the H3 application instance.
Expand Down Expand Up @@ -72,19 +73,19 @@ export class H3Event<
/**
* Event context.
*/
readonly context: H3EventContext;
readonly context: _ContextT;

/**
* @internal
*/
static __is_event__ = true;

constructor(req: ServerRequest, context?: H3EventContext, app?: H3Core) {
constructor(req: ServerRequest, context?: _ContextT, app?: H3Core) {
// Keep `event.context` and `req.context` as the same reference so utilities
// reading `event.req.context` (e.g. getRequestIP) observe writes to
// `event.context`. Without the write-back, an explicit `context` or an
// unset `req.context` leaves the two objects diverged.
this.context = req.context = context || req.context || new EmptyObject();
this.context = req.context = (context || req.context || new EmptyObject()) as _ContextT;
this.req = req;
this.app = app;
// Parsed URL can be provided by srvx (node) and other runtimes
Expand Down
43 changes: 34 additions & 9 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@ import type {
HTTPHandler,
} from "./types/handler.ts";
import type { StandardSchemaV1, InferOutput } from "./utils/internal/standard-schema.ts";
import type { TypedH3EventContext } from "./types/context.ts";
import type { TypedRequest } from "fetchdts";
import { NoHandler, type H3Core } from "./h3.ts";
import { validatedRequest, validatedURL, type OnValidateError } from "./utils/internal/validate.ts";
import {
validatedRequest,
validatedURL,
validatedParams,
type OnValidateError,
} from "./utils/internal/validate.ts";
import { chain } from "./utils/internal/promise.ts";

// --- event handler ---

Expand Down Expand Up @@ -50,7 +57,7 @@ export function defineHandler(input: EventHandler | EventHandlerObject): EventHa
);
}

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

Expand All @@ -61,33 +68,51 @@ export function defineValidatedHandler<
RequestBody extends StandardSchemaV1,
RequestHeaders extends StandardSchemaV1,
RequestQuery extends StandardSchemaV1,
// `undefined` default marks "no params schema" so the context override below
// only applies (and makes `params` required) when one is actually declared.
RequestParams extends StandardSchemaV1 | undefined = undefined,
Res extends EventHandlerResponse = EventHandlerResponse,
>(
def: Omit<EventHandlerObject, "handler"> & {
validate?: {
body?: RequestBody;
headers?: RequestHeaders;
query?: RequestQuery;
params?: RequestParams;
decodeParams?: boolean;
onError?: OnValidateError;
};
handler: EventHandler<
{
body: InferOutput<RequestBody>;
query: StringHeaders<InferOutput<RequestQuery>>;
query: StringsOnly<InferOutput<RequestQuery>>;
},
Res
Res,
TypedH3EventContext<
RequestParams extends StandardSchemaV1 ? { params: InferOutput<RequestParams> } : {}
>
>;
},
): EventHandlerWithFetch<TypedRequest<InferOutput<RequestBody>, InferOutput<RequestHeaders>>, Res> {
if (!def.validate) {
return defineHandler(def) as any;
// context-typed handler narrows the event param (contravariant) — safe at runtime
return defineHandler(def as any) as any;
}
return defineHandler({
...def,
handler: async function _validatedHandler(event) {
(event as any) /* readonly */.req = await validatedRequest(event.req, def.validate!);
(event as any) /* readonly */.url = await validatedURL(event.url, def.validate!);
return def.handler(event as any);
handler: function _validatedHandler(event) {
const v = def.validate!;
// `chain` keeps a fully-sync path from yielding a microtask.
// params → headers → query in sequential order
return chain(validatedParams(event, v), () =>
chain(validatedRequest(event.req, v), (req) => {
(event as any) /* readonly */.req = req;
return chain(validatedURL(event.url, v), (url) => {
(event as any) /* readonly */.url = url;
return def.handler(event as any);
});
}),
);
},
}) as any;
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export { definePlugin } from "./plugin.ts";

// Event

export type { H3EventContext } from "./types/context.ts";
export type { H3EventContext, TypedH3EventContext } from "./types/context.ts";
export { H3Event, type HTTPEvent } from "./event.ts";
export { isEvent, isHTTPEvent, mockEvent, getEventContext } from "./utils/event.ts";

Expand Down
14 changes: 13 additions & 1 deletion src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { RouteRules } from "./route-rules.ts";
import type { ServerRequestContext } from "srvx";

export interface H3EventContext extends ServerRequestContext {
/* Matched router parameters */
/* Matched router parameters (typed/coerced when validated) */
params?: Record<string, string>;

/* Matched middleware parameters */
Expand Down Expand Up @@ -36,3 +36,15 @@ export interface H3EventContext extends ServerRequestContext {
/* Server-Timing entries collected via setServerTiming / withServerTiming */
timing?: Array<{ name: string } & Record<string, unknown>>;
}

/**
* Typed view over {@link H3EventContext} with specific fields replaced.
*
* Overridden keys become required and fully typed (e.g. schema-coerced params in
* `defineValidatedHandler`); every other field — including `declare module`
* augmentations — keeps its base type. The mapped form (instead of `Omit`)
* preserves literal keys next to `ServerRequestContext`'s index signature.
*/
export type TypedH3EventContext<Overrides = {}> = {
[K in keyof H3EventContext as K extends keyof Overrides ? never : K]: H3EventContext[K];
} & Overrides;
6 changes: 4 additions & 2 deletions src/types/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ServerRequest } from "srvx";
import type { ServerRequest, ServerRequestContext } from "srvx";
import type { TypedRequest, TypedResponse, ResponseHeaderMap } from "fetchdts";
import type { H3Event, HTTPEvent } from "../event.ts";
import type { H3EventContext } from "./context.ts";
import type { MaybePromise } from "./_utils.ts";
import type { H3RouteMeta } from "./h3.ts";
import type { H3Core } from "../h3.ts";
Expand All @@ -12,8 +13,9 @@ export type HTTPHandler = EventHandler | FetchableObject | H3Core;
export interface EventHandler<
_RequestT extends EventHandlerRequest = EventHandlerRequest,
_ResponseT extends EventHandlerResponse = EventHandlerResponse,
_ContextT extends ServerRequestContext = H3EventContext,
> {
(event: H3Event<_RequestT>): _ResponseT;
(event: H3Event<_RequestT, _ContextT>): _ResponseT;
meta?: H3RouteMeta;
}

Expand Down
13 changes: 13 additions & 0 deletions src/utils/internal/promise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Continue with `fn` once `value` settles, staying synchronous when it already is.
*
* Avoids the `async`/`await` microtask tick on the common sync path while
* collapsing the repeated thenable check into one shared helper. Duck-types
* `then` (instead of `instanceof Promise`) to support cross-realm promises
* and custom thenables.
*/
export function chain<T, R>(value: T | PromiseLike<T>, fn: (value: T) => R): R | Promise<R> {
return typeof (value as PromiseLike<T>)?.then === "function"
? ((value as PromiseLike<T>).then(fn) as Promise<R>)
: fn(value as T);
}
105 changes: 79 additions & 26 deletions src/utils/internal/validate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { type ErrorDetails, HTTPError } from "../../error.ts";
import { chain } from "./promise.ts";
import { getRouterParams } from "../request.ts";

import type { ServerRequest } from "srvx";
import type { StandardSchemaV1, FailureResult, InferOutput, Issue } from "./standard-schema.ts";
import type { H3Event } from "../../event.ts";
import type {
StandardSchemaV1,
FailureResult,
InferOutput,
Issue,
Result,
} from "./standard-schema.ts";

export type ValidateResult<T> = T | true | false | void;

Expand Down Expand Up @@ -75,7 +84,7 @@ export async function validateData<T>(
// prettier-ignore
const reqBodyKeys = /* @__PURE__ */ new Set(["body", "text", "formData", "arrayBuffer"]);

export async function validatedRequest<
export function validatedRequest<
RequestBody extends StandardSchemaV1,
RequestHeaders extends StandardSchemaV1,
>(
Expand All @@ -85,20 +94,30 @@ export async function validatedRequest<
headers?: RequestHeaders;
onError?: OnValidateError;
},
): Promise<ServerRequest> {
// Validate Headers
): ServerRequest | Promise<ServerRequest> {
if (validate.headers) {
const validatedheaders = await validateSource(
const validated = validateSource(
"headers",
Object.fromEntries(req.headers.entries()),
validate.headers as StandardSchemaV1<Record<string, string>>,
validate.onError,
);
for (const [key, value] of Object.entries(validatedheaders)) {
req.headers.set(key, value);
}
const applyHeaders = (headers: Record<string, string>): ServerRequest => {
for (const [key, value] of Object.entries(headers)) {
req.headers.set(key, value);
}
return bodyProxy(req, validate);
};
return chain(validated, applyHeaders);
}

return bodyProxy(req, validate);
}

function bodyProxy(
req: ServerRequest,
validate: { body?: StandardSchemaV1; onError?: OnValidateError },
): ServerRequest {
if (!validate.body) {
return req;
}
Expand Down Expand Up @@ -144,47 +163,81 @@ export async function validatedRequest<
});
}

export async function validatedURL(
export function validatedURL(
url: URL,
validate: {
query?: StandardSchemaV1;
onError?: OnValidateError;
},
): Promise<URL> {
): URL | Promise<URL> {
if (!validate.query) {
return url;
}

const validatedQuery = await validateSource(
const validated = validateSource(
"query",
Object.fromEntries(url.searchParams.entries()),
validate.query as StandardSchemaV1<Record<string, string>>,
validate.onError,
);

for (const [key, value] of Object.entries(validatedQuery)) {
url.searchParams.set(key, value);
const applyQuery = (query: Record<string, string>): URL => {
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value);
}
return url;
};

return chain(validated, applyQuery);
}

export function validatedParams(
event: H3Event,
validate: {
params?: StandardSchemaV1;
decodeParams?: boolean;
onError?: OnValidateError;
},
): Record<string, string> | undefined | Promise<Record<string, string>> {
if (!validate.params) {
return event.context.params;
}

return url;
const validated = validateSource(
"params",
getRouterParams(event, { decode: validate.decodeParams }),
validate.params as StandardSchemaV1<Record<string, string>>,
validate.onError,
);

// Replace (not merge): schema output is the source of truth.
const applyParams = (params: Record<string, string>): Record<string, string> => {
event.context.params = params;
return params;
};

return chain(validated, applyParams);
}

async function validateSource<Source extends "headers" | "query", T = unknown>(
function validateSource<Source extends "headers" | "query" | "params", T = unknown>(
source: Source,
data: unknown,
fn: StandardSchemaV1<T>,
onError?: OnValidateError,
): Promise<T> {
const result = await fn["~standard"].validate(data);
if (result.issues) {
throw createValidationError(
onError?.({ _source: source, ...result }) || {
message: VALIDATION_FAILED,
issues: result.issues,
},
);
}
return result.value;
): T | Promise<T> {
const finish = (result: Result<T>): T => {
if (result.issues) {
throw createValidationError(
onError?.({ _source: source, ...result }) || {
message: VALIDATION_FAILED,
issues: result.issues,
},
);
}
return result.value;
};

return chain(fn["~standard"].validate(data), finish);
}

function createValidationError(cause: Error | HTTPError | ErrorDetails | FailureResult) {
Expand Down
Loading
Loading