⬡ server.ts
⬡ client.ts
⬡ types.ts
⚙ wrangler.toml
// src/server.ts — NutriTrack API • Cloudflare Workers + Hono RPC import { Hono } from "hono" import { zValidator } from "@hono/zod-validator" import { cors } from "hono/cors" import { bearerAuth } from "hono/bearer-auth" import { logger } from "hono/logger" import { HTTPException } from "hono/http-exception" import { z } from "zod" // ─── Zod Schemas ───────────────────────────────────────────────────────────── const patientSchema = z.object({ name: z.string().min(2).max(120), email: z.string().email(), weight_kg: z.number().positive().max(300), height_cm: z.number().positive().max(250), goal: z.enum(["lose_weight", "gain_muscle", "maintain", "medical"]), notes: z.string().max(1000).optional(), }) const planSchema = z.object({ title: z.string().min(3), kcal_target: z.number().int().min(800).max(5000), protein_g: z.number().positive(), carbs_g: z.number().positive(), fat_g: z.number().positive(), duration_days: z.number().int().positive(), }) const intakeSchema = z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), kcal: z.number().int().positive(), protein_g: z.number().positive(), carbs_g: z.number().positive(), fat_g: z.number().positive(), meal_log: z.array(z.object({ name: z.string(), kcal: z.number().positive(), })).optional(), }) const listQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(20), search: z.string().optional(), goal: z.enum(["lose_weight", "gain_muscle", "maintain", "medical"]).optional(), }) // ─── App & Middleware ───────────────────────────────────────────────────────── const app = new Hono<{ Bindings: CloudflareBindings }>() app.use("*", logger()) app.use("/api/*", cors({ origin: ["https://nutritrack.app", "https://app.nutritrack.app"], allowMethods: ["GET", "POST", "PATCH", "DELETE"], })) app.use("/api/*", bearerAuth({ token: (c) => c.env.API_SECRET })) // ─── Patients Router ────────────────────────────────────────────────────────── const patients = new Hono<{ Bindings: CloudflareBindings }>() .get("/", zValidator("query", listQuerySchema), async (c) => { const { page, limit, search, goal } = c.req.valid("query") // ✅ typed const db = c.env.DB let query = "SELECT * FROM patients WHERE 1=1" const params: unknown[] = [] if (search) { query += " AND name LIKE ?"; params.push(`%${search}%`) } if (goal) { query += " AND goal = ?"; params.push(goal) } query += ` LIMIT ${limit} OFFSET ${(page - 1) * limit}` const result = await db.prepare(query).bind(...params).all() return c.json({ patients: result.results, page, limit }) } ) .post("/", zValidator("json", patientSchema), async (c) => { const data = c.req.valid("json") // ✅ { name, email, weight_kg, height_cm, goal, notes? } const bmi = data.weight_kg / ((data.height_cm / 100) ** 2) const id = crypto.randomUUID() await c.env.DB .prepare("INSERT INTO patients (id,name,email,weight_kg,height_cm,bmi,goal,notes) VALUES (?,?,?,?,?,?,?,?)") .bind(id, data.name, data.email, data.weight_kg, data.height_cm, bmi.toFixed(1), data.goal, data.notes ?? null) .run() return c.json({ patient: { id, ...data, bmi: +bmi.toFixed(1) } }, 201) } ) .get("/:id", async (c) => { const id = c.req.param("id") const patient = await c.env.DB .prepare("SELECT * FROM patients WHERE id = ?").bind(id).first() if (!patient) throw new HTTPException(404, { message: "Patient not found" }) return c.json({ patient }) }) .patch("/:id", zValidator("json", patientSchema.partial()), async (c) => { const { id } = c.req.param() const updates = c.req.valid("json") // ✅ Partial<Patient> const sets = Object.keys(updates).map((k) => `${k} = ?`).join(", ") const values = [...Object.values(updates), id] await c.env.DB .prepare(`UPDATE patients SET ${sets}, updated_at = datetime('now') WHERE id = ?`) .bind(...values).run() return c.json({ ok: true, id, ...updates }) } ) .delete("/:id", async (c) => { const id = c.req.param("id") await c.env.DB.prepare("DELETE FROM patients WHERE id = ?").bind(id).run() return c.json({ deleted: id }) }) .get("/:id/plans", async (c) => { const id = c.req.param("id") const plans = await c.env.DB .prepare("SELECT * FROM plans WHERE patient_id = ? ORDER BY created_at DESC") .bind(id).all() return c.json({ plans: plans.results }) }) .post("/:id/plans", zValidator("json", planSchema), async (c) => { const patientId = c.req.param("id") const plan = c.req.valid("json") // ✅ typed plan data const planId = crypto.randomUUID() await c.env.DB .prepare("INSERT INTO plans (id,patient_id,title,kcal_target,protein_g,carbs_g,fat_g,duration_days) VALUES (?,?,?,?,?,?,?,?)") .bind(planId, patientId, plan.title, plan.kcal_target, plan.protein_g, plan.carbs_g, plan.fat_g, plan.duration_days) .run() return c.json({ plan: { id: planId, patient_id: patientId, ...plan } }, 201) } ) .post("/:id/intake", zValidator("json", intakeSchema), async (c) => { const patientId = c.req.param("id") const intake = c.req.valid("json") // ✅ typed intake const intakeId = crypto.randomUUID() await c.env.DB .prepare("INSERT INTO intake_log (id,patient_id,date,kcal,protein_g,carbs_g,fat_g,meal_log_json) VALUES (?,?,?,?,?,?,?,?)") .bind(intakeId, patientId, intake.date, intake.kcal, intake.protein_g, intake.carbs_g, intake.fat_g, JSON.stringify(intake.meal_log ?? [])) .run() return c.json({ intake: { id: intakeId, patient_id: patientId, ...intake } }, 201) } ) // ─── Mount & Global Error Handler ──────────────────────────────────────────── const api = app.route("/api/patients", patients) app.onError((err, c) => { if (err instanceof HTTPException) return c.json({ error: err.message }, err.status) return c.json({ error: "Internal server error" }, 500) }) // ─── KEY: Export AppType for RPC client ────────────────────────────────────── export type AppType = typeof api // 🔑 This is the only thing the client needs export default app
⬡ server.ts
⬡ client.ts
⬡ types.ts
// src/client.ts — NutriTrack Frontend Client (React / Next.js) import { hc } from "hono/client" import type { InferRequestType, InferResponseType } from "hono/client" import type { AppType } from "./server" // ← solo el tipo, 0 bytes en bundle // ─── Typed client ───────────────────────────────────────────────────────────── export const api = hc<AppType>("https://api.nutritrack.app", { headers: () => ({ Authorization: `Bearer ${localStorage.getItem("nt_token") ?? ""}`, }), }) // ─── Infer types for forms and hooks ────────────────────────────────────────── export type CreatePatientInput = InferRequestType<typeof api.api.patients.$post>["json"] export type PatientResponse = InferResponseType<typeof api.api.patients.$post, 201> export type CreatePlanInput = InferRequestType<typeof api.api.patients[":id"].plans.$post>["json"] // ─── Service layer ──────────────────────────────────────────────────────────── export const patientService = { /** Lista pacientes. Todos los tipos inferidos de AppType. */ list: async (params?: { page?: string; search?: string }) => { const res = await api.api.patients.$get({ query: { page: params?.page ?? "1", search: params?.search }, }) if (!res.ok) throw new Error("Failed to load patients") return res.json() // → { patients: Patient[], page, limit } }, /** Crea un paciente nuevo — cuerpo validado por Zod antes de enviar. */ create: async (data: CreatePatientInput) => { const res = await api.api.patients.$post({ json: data }) if (!res.ok) throw new Error("Failed to create patient") return res.json() // → { patient: { id, name, email, bmi, ... } } }, /** Obtiene detalle del paciente. */ get: async (id: string) => { const res = await api.api.patients[":id"].$get({ param: { id } }) if (!res.ok) throw new Error(`Patient ${id} not found`) return res.json() }, /** Registra ingesta del día. */ logIntake: async (patientId: string, intake: { date: string; kcal: number; protein_g: number; carbs_g: number; fat_g: number; }) => { const res = await api.api.patients[":id"].intake.$post({ param: { id: patientId }, json: intake, }) return res.json() }, } // ─── Usage in React component ───────────────────────────────────────────────── /* import { patientService, CreatePatientInput } from "./client" function NewPatientForm() { const handleSubmit = async (data: CreatePatientInput) => { const { patient } = await patientService.create(data) // ^^ fully typed: { id, name, email, weight_kg, bmi, goal, ... } console.log("Created:", patient.id, "BMI:", patient.bmi) } } */
★ Por qué Hono RPC para NutriTrack
Cero errores de tipo en prod
Si el backend cambia un campo de
weight_kg a weight, TypeScript falla en compilación, no en runtime.Sin generación de código
No hay
openapi-generator ni schemas extra. Solo export type AppType = typeof app.Edge-first: <10ms latencia
Cloudflare Workers ejecuta el mismo código a 300+ nodos globales. Gratis en el plan Workers Paid.
Validación Zod en ambos extremos
El servidor rechaza datos inválidos con 400. El cliente infiere los tipos exactos del schema Zod del servidor.