defineApiRoute
Declara un endpoint de server/api con sus schemas públicos, guardas, estado y handler; el servidor valida todo y genera el cliente api.* tipado.
Se autoimporta en el código de servidor: server/ y <script server>.
import { defineApiRoute } from "@meekojs/meeko/server";defineApiRoute declara un endpoint HTTP: el export por defecto de server/api/<ruta>.<método>.ts. server/api/users/[id].get.ts atiende GET /api/users/:id. El servidor valida la entrada con los schemas (params, query, body), la sesión, la autorización, el Origin y el token CSRF, ejecuta el handler y valida su resultado con response.
Los schemas públicos viven en shared/ y son Standard Schema (Valibot, Zod, ArkType…). A partir de ellos, Meeko genera el cliente api.*, que useApi y useApiMutation usan con los mismos tipos, sin importar el handler. Desde 1.7, form hace que el mismo endpoint atienda también un <form method="post"> nativo, sin JavaScript.
Uso#
// shared/schemas/user.ts
import * as v from "valibot";
export const userParams = v.object({ id: v.string() });
export const userSchema = v.object({ id: v.string(), name: v.string() });// server/api/users/[id].get.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { userParams, userSchema } from "../../../shared/schemas/user";
export default defineApiRoute({
params: userParams,
response: userSchema,
handler: async ({ params }) => {
const user = await findUser(params.id);
if (!user) throw new ApiError("NOT_FOUND", 404);
return { id: user.id, name: user.name };
},
});En la aplicación, await api.users({ id: "7" }).get() devuelve { id: string; name: string }.
Tipo#
function defineApiRoute<
P extends Schema | undefined = undefined,
Q extends Schema | undefined = undefined,
B extends Schema | undefined = undefined,
R extends Schema | undefined = undefined,
S = unknown,
L = undefined,
T extends ApiSuccessStatus = ApiSuccessStatus,
>(
definition: ApiRouteDefinition<P, Q, B, R, S, L, T>,
): ApiRouteDefinition<P, Q, B, R, S, L, T>interface ApiRouteDefinition<
P extends Schema | undefined,
Q extends Schema | undefined,
B extends Schema | undefined,
R extends Schema | undefined,
S = unknown,
L = undefined,
T extends ApiSuccessStatus = ApiSuccessStatus,
> {
params?: P;
query?: Q;
body?: B;
response?: R;
status?: T;
auth?: "required" | "optional";
origin?: "required" | "none";
rateLimit?: RateLimitOptions<S>;
contentType?: "json" | "response";
form?: true | ApiFormOptions<ApiFormResult<R, T>, S>;
authorize?: (context: RequestContext<S>) => boolean | Promise<boolean>;
locals?: (context: RequestContext<S>) => L | Promise<L>;
handler: (args: {
params: OptionalOutput<P>;
query: OptionalOutput<Q>;
body: OptionalOutput<B>;
context: RequestContext<S>;
locals: L;
}) => Promise<ApiHandlerResult<R, T>> | ApiHandlerResult<R, T>;
}interface ApiFormOptions<Result = unknown, S = unknown> {
readonly redirect?: (
result: Result,
context: RequestContext<S>,
) => string | Promise<string>;
readonly maxBytes?: number;
readonly files?: boolean;
readonly echo?: readonly string[];
}Opciones#
| Opción | Tipo | Por defecto | Descripción |
|---|---|---|---|
params |
Standard Schema | ninguno | Los segmentos dinámicos ([id]), como cadenas obligatorias. Obligatorio en una ruta dinámica y prohibido en una estática. |
query |
Standard Schema | ninguno | La query string. Sus campos aceptan texto o listas de texto; convierte los números dentro del schema. |
body |
Standard Schema | ninguno | El cuerpo JSON (y, con form, el del formulario). Límite de 1 MiB. |
response |
Standard Schema | obligatorio salvo con status: 204 o contentType: "response" |
El resultado del handler. Uno que no lo supera responde 500 sin detalle. |
status |
200 | 201 | 202 | 204 |
200 |
Desde 1.2. Estado de una respuesta correcta. Con 204 no hay cuerpo ni response. Debe ser un literal. |
auth |
"required" | "optional" |
"optional" |
Con "required", una petición sin sesión responde 401. |
authorize |
(context) => boolean | Promise<boolean> |
ninguno | Con false, 403 FORBIDDEN. |
origin |
"required" | "none" |
Origin obligatorio en POST, PUT, PATCH y DELETE (desde 1.8) |
Desde 1.8, toda mutación exige un Origin permitido, haya o no sesión; "required" lo deja explícito. "none" (inseguro) lo desactiva en las peticiones sin sesión, también en los formularios nativos, para un webhook (JSON o form-encoded) o una API pública; con sesión siguen Origin y CSRF. |
rateLimit |
{ limit, window, key? } |
ninguno | Desde 1.2. Límite de peticiones por ventana; por encima, 429 con Retry-After. Sin key, cuenta por IP del cliente (context.clientAddress; tras Cloudflare Tunnel, MEEKO_TRUST_PROXY=cloudflare). |
locals |
(context) => L | Promise<L> |
ninguno | Desde 1.2. Un valor por petición (el usuario actual) que el handler recibe tipado en locals. Decláralo antes de handler. |
contentType |
"json" | "response" |
"json" |
Con "response", el handler devuelve su propio Response (un archivo, un CSV, un stream). |
form |
true o { redirect?, maxBytes?, files?, echo? } |
sin formularios | Desde 1.7. Acepta application/x-www-form-urlencoded y multipart/form-data, validados con body, y responde 303 a la página. echo lista los campos cuyo valor vuelve a la página tras un error; por defecto, ninguno. Véase abajo. |
handler |
({ params, query, body, context, locals }) => resultado |
obligatorio | La lógica. context es el RequestContext: session, services, platform, requestId, clientAddress, signal. |
El orden de cada petición es: Origin (desde 1.8, en toda mutación salvo con origin: "none"), Origin y CSRF de la sesión, rateLimit, auth, authorize, validación de la entrada, locals, handler y validación de la respuesta. Un formulario nativo cuenta rateLimit antes, tras Origin y antes de leer el cuerpo, porque el token CSRF viaja en él.
Valor devuelto#
La misma definición, con sus tipos inferidos. Es lo que el servidor ejecuta y de lo que meeko routes, meeko check y meeko dev generan el contrato del cliente. Las respuestas:
| Caso | Respuesta |
|---|---|
| Correcta | El estado de status, con el JSON de response (o sin cuerpo con 204) |
| Entrada inválida | 400 INVALID_PARAMS, INVALID_QUERY o INVALID_BODY, con issues: [{ path, message }] (como mucho 20) |
ApiError(code, status) |
Ese estado, con { code, status, requestId } y sus issues si es un 4xx |
| Cualquier otro error | 500 INTERNAL_ERROR, sin el mensaje; el servidor lo registra |
Formulario nativo (form) |
303 See Other a la página, con el resultado en una cookie de un solo uso |
Ejemplos#
Crear con 201 y la sesión#
// server/api/orders/index.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { newOrder, orderSchema } from "../../../shared/schemas/order";
export default defineApiRoute({
auth: "required",
body: newOrder,
response: orderSchema,
status: 201,
rateLimit: { limit: 20, window: "1m" },
locals: async ({ session }) => {
const user = await findUser((session.current?.data as { userId: string }).userId);
if (!user) throw new ApiError("UNAUTHENTICATED", 401);
return { user };
},
handler: ({ body, locals }) => createOrder(locals.user, body),
});Borrar con 204#
// server/api/orders/[id].delete.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { orderParams } from "../../../shared/schemas/order";
export default defineApiRoute({
params: orderParams,
auth: "required",
status: 204,
handler: async ({ params }) => { await deleteOrder(params.id); },
});await api.orders({ id }).delete() resuelve undefined.
Un error en un campo#
Desde 1.5, un ApiError con issues lleva el mensaje a un campo, que useForm muestra junto a él:
// server/api/signup.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
import { signupInput, signupResult } from "../../shared/schemas/signup";
export default defineApiRoute({
origin: "required",
body: signupInput,
response: signupResult,
status: 201,
handler: async ({ body }) => {
if (await emailTaken(body.email)) {
throw new ApiError("EMAIL_TAKEN", 409, { message: `duplicate ${body.email}`, issues: [{ path: "email", message: "Ese correo ya tiene una cuenta." }] });
}
return createUser(body);
},
});El message del tercer argumento es interno y nunca se envía; los issues son públicos.
Un formulario sin JavaScript#
Desde 1.7, con form, el mismo endpoint atiende el envío nativo del navegador. Los campos llegan como texto y, si el schema los rechaza, Meeko los vuelve a validar una vez con su lectura tipada ("" → undefined, "on"/"true" → true, "false" → false, un número decimal → number), así que v.boolean() y v.number() sirven tal cual. Un schema asíncrono se ejecuta una sola vez, sin ese segundo intento:
// server/api/feedback.post.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { feedbackInput, feedbackSaved } from "../../shared/schemas/feedback";
export default defineApiRoute({
body: feedbackInput,
response: feedbackSaved,
// Tras un error, el comentario vuelve a la página; ningún otro valor.
form: { redirect: (saved) => `/gracias?id=${saved.id}`, echo: ["comment"] },
handler: ({ body }) => saveFeedback(body),
});<!-- app/components/Feedback.meeko -->
<script lang="ts">
const route = useRoute();
const form = useForm({
schema: feedbackInput,
initial: { page: route.path.value, helpful: false, comment: "" },
action: "/api/feedback",
submit: (values) => api.feedback.post({ body: values }),
});
</script>
<template>
<form method="post" action="/api/feedback" on:submit="form.submit" novalidate>
<input type="hidden" name="return" bind:value="route.url.value">
<input type="hidden" name="_csrf" bind:value="form.csrfToken">
<input type="hidden" name="page" bind:value="form.fields.page.value">
<p if:when="form.error.value" role="alert">{form.error.value}</p>
<textarea name="comment" model="form.fields.comment" aria-label="Comentario"></textarea>
<p if:when="form.errors.value.comment">{form.errors.value.comment}</p>
<button name="helpful" value="true" on:click="() => { form.fields.helpful.value = true; }">Me ha servido</button>
<button name="helpful" value="false" on:click="() => { form.fields.helpful.value = false; }">No me ha servido</button>
</form>
</template>Con JavaScript, form.submit cancela el envío nativo y manda JSON. Sin él, el navegador envía el formulario con el name y el value del botón pulsado (helpful=true llega al schema como true); un envío correcto responde 303 a form.redirect (o al campo return, al Referer o a /), y un error vuelve con 303 a la página, que pinta en el SSR los mensajes del resultado y los valores de los campos de form.echo, guardado en la cookie __Host-meeko-form (HttpOnly, 60 segundos, de un solo uso).
Un archivo con contentType: "response"#
// server/api/reports/[id].get.ts
import { defineApiRoute } from "@meekojs/meeko/server";
import { reportParams } from "../../../shared/schemas/report";
export default defineApiRoute({
params: reportParams,
auth: "required",
contentType: "response",
handler: async ({ params }) => new Response(await renderCsv(params.id), {
headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${params.id}.csv"` },
}),
});Un webhook firmado con origin: "none"#
Un webhook llega de otro servidor sin Origin, y desde 1.8 una mutación sin Origin responde 403 INVALID_ORIGIN. origin: "none" desactiva esa comprobación en la ruta, así que el handler debe probar de otra forma que la petición es legítima, como con la firma del proveedor:
// server/api/webhooks/payments.post.ts
import { ApiError, defineApiRoute } from "@meekojs/meeko/server";
export default defineApiRoute({
origin: "none",
contentType: "response",
handler: async ({ context }) => {
const payload = await context.request.text();
if (!(await verifySignature(payload, context.request.headers.get("x-signature")))) throw new ApiError("INVALID_SIGNATURE", 401);
await recordPayment(JSON.parse(payload));
return new Response(null, { status: 204 });
},
});Errores comunes#
403 INVALID_ORIGIN en un webhook, un script o una prueba#
Desde 1.8, un POST, PUT, PATCH o DELETE sin Origin, con Origin: null o con el de otra web responde 403 INVALID_ORIGIN, también sin sesión. El navegador y los clientes api.* envían el Origin solos; fetch de Node o curl, no.
await fetch("http://localhost:4173/api/orders", { method: "POST", headers: { "content-type": "application/json" }, body });await fetch("http://localhost:4173/api/orders", { method: "POST", headers: { "content-type": "application/json", origin: "http://localhost:4173" }, body });Para un webhook o una API pública que no puede enviarlo, declara origin: "none" en la ruta y verifica una firma o un token (véase el ejemplo de arriba). Un cliente de otro origen conocido va en SessionManager({ allowedOrigins }). Véase Origin en mutaciones anónimas.
Un schema fuera de shared/, o sin response#
El cliente se genera desde los schemas públicos sin ejecutar el handler, así que cada uno debe venir de shared/, que el navegador puede importar. Desde 1.8 se admite una composición simple de ellos con funciones de un paquete, como v.array(item) con item importado de shared/, pero no un schema local del handler.
// server/api/items.get.ts
const item = v.object({ id: v.string() });
export default defineApiRoute({ response: v.array(item), handler: () => [] });meeko check se detiene con server/api/items.get.ts: response debe venir de shared/: item no se importa de shared/ ni de un paquete, …. Sin response: la API requiere un schema público response. Véase Schemas públicos.
import { itemList } from "../../shared/schemas/items";
export default defineApiRoute({ response: itemList, handler: () => [] });Una ruta dinámica sin params#
// server/api/orders/[id].delete.ts
export default defineApiRoute({ status: 204, handler: async () => {} });server/api/orders/[id].delete.ts: una ruta de API dinámica requiere un schema público params.
export default defineApiRoute({ params: orderParams, status: 204, handler: async ({ params }) => { await deleteOrder(params.id); } });status calculado, o 204 con response#
const deleted = 204;
export default defineApiRoute({ params: orderParams, response: orderSchema, status: deleted, handler: async () => {} });status debe ser el literal 200, 201, 202 o 204. Con el literal 204 y response: status 204 responde sin cuerpo y no admite un schema response.
export default defineApiRoute({ params: orderParams, status: 204, handler: async () => {} });Un resultado que no coincide con response#
export default defineApiRoute({ body: newOrder, response: orderSchema, handler: ({ body }) => ({ id: 1, item: body.item, qty: body.qty }) });meeko check da TS2322: Type 'number' is not assignable to type 'string'. en el handler. En ejecución, un resultado que no supera el schema responde 500 sin detalle.
export default defineApiRoute({ body: newOrder, response: orderSchema, handler: ({ body }) => ({ id: "1", item: body.item, qty: body.qty }) });Llamarlo desde un componente#
<script lang="ts">
const route = defineApiRoute({ handler: () => ({}) });
</script>meeko check da MEEK108: defineApiRoute viene de @meekojs/meeko/server, que sólo existe en el servidor: el <script> y la plantilla de un componente también se ejecutan en el navegador. Úsalo en los handlers de server/api. Un componente llama al endpoint con api.*, useApi o useApiMutation.
<script lang="ts">
const users = useApi("/api/users");
</script>Notas#
- Seguridad. Valida siempre con schemas: la entrada nunca llega al handler sin pasar por ellos.
auth,authorizeylocalsson las guardas del endpoint: el middleware de ruta no protegeserver/api, aunque sólo lo llamen páginas protegidas. Un error que no esApiErrornunca expone su mensaje. - CSRF y Origin. Con sesión, cada mutación exige el
Originpermitido y el token CSRF (x-meeko-csrf, que el cliente generado envía solo). Un formulario nativo (form) exigeOrigin(desde1.8, salvo sin sesión conorigin: "none") y, con sesión, el campo_csrf(form.csrfTokendeuseForm); esos dos fallos responden403con JSON, sin303. - SSR. En el servidor,
api.*despacha localmente por el mismo pipeline, con la sesión y los servicios de la petición original, sin salir a la red. - Formularios (
1.7). Un nombre repetido o terminado en[]es una lista;returny_csrfno llegan al schema; una casilla sin marcar no envía nada (decláralo conv.optional(v.boolean(), false)). Los archivos necesitanform.files: true(si no,400 FILES_NOT_ALLOWED). La cookie sólo guarda los valores deform.echo(por defecto ninguno), nunca archivos ni campos cuyo nombre contienepassword,passcode,secret,token,cvcocvv: no pongas enechonúmeros de tarjeta, PIN ni otros datos sensibles, porque vuelven en la cookie y en el HTML. Una página prerenderizada no lleva el token CSRF en su HTML: un usuario con sesión que la envía sin JavaScript recibe403 INVALID_CSRF, así que un formulario para usuarios con sesión va en una página"ssr". Sinform, un formulario recibe415 UNSUPPORTED_MEDIA_TYPE. Conformy sinbody, el handler recibebody: undefinedy los campos del formulario se descartan sin aviso: sirve para un formulario de sólo un botón (cerrar sesión, borrar lo que dicen losparams), así que no es un error; si el formulario tiene campos, declara su schema enbody. - Rendimiento.
rateLimitcuenta en memoria por instancia salvo que el store de sesiones lo comparta (Redis o, desde1.7, SQL conrateLimiten el driver). - OpenAPI.
generateOpenApidocumenta el contrato JSON, con el estado declarado; no describe el cuerpo de formulario.