@hablala/client
v0.23.0
Published
SDK headless de Hablalá — cliente tipado de la Public API + helpers de loader para React Router.
Maintainers
Readme
@hablala/client
SDK headless de Hablalá — el cliente oficial de la Public API para tu frontend.
Hablalá es un aPaaS headless, metadata-driven: modelas tus objects (tu propio modelo de datos,
cualquier dominio — tienda, clínica, escuela), los operas por API, y tu app corre sobre nuestra
infraestructura. El vertical (commerce, crm…) es un package instalable, no la identidad de la
plataforma: el core es horizontal. El frontend lo escribes tú, en tu propio proyecto React
Router. Este paquete te da las "baterías incluidas": un cliente tipado que lee y escribe la
Public API, cuentas de usuario final (auth + me) y helpers de loader.
Cero componentes de UI. El diseño es 100% tuyo. Este SDK solo mueve datos.
De un vistazo (los slugs product/order son de ejemplo — usa los objects de tu modelo):
const hablala = createHablalaClient({ token: "pk_…" });
await hablala.query({ object: "product" }); // leer (contenido publicado)
await hablala.create("product_review", { rating: 5 }); // escribir (canal público)
await hablala.auth.login({ email, password }); // cuenta de usuario final
await hablala.me.query({ object: "order" }); // "mis records" (auto-acotado)Arquitectura de la plataforma:
apps/api/ARCHITECTURE.md. Contrato API-first (single source of truth):apps/api/API_FIRST.md.
Instalación
npm install @hablala/clientPeer dependency opcional: react-router (v8) si usas los helpers de loader. El
cliente en sí es agnóstico y funciona en cualquier runtime con fetch.
Quickstart
import { createHablalaClient } from "@hablala/client";
const hablala = createHablalaClient({
token: "pk_…", // API key (publishable) — basta con esto (ver abajo)
// endpoint es opcional (default https://api.hablala.com, host raíz SIN /v1).
});
const { data } = await hablala.query({
object: "obra",
where: [{ attr: "estado", op: "eq", value: "en_progreso" }],
include: [{ relationship: "cliente_obra", select: ["nombre"] }],
order_by: [{ attr: "fecha_entrega", dir: "asc" }],
});
// data: [{ id: "rec_…", properties: { nombre, estado, cliente_obra: [...] } }]Un token, cero configuración. No pasas
organizationId: la organización se infiere del propio token. Una credencial es una identidad completa (el estándar de la industria) — el token porta su tenant, no falsificable. La postura (publishable/secret) también sale del prefijo de la key, así que tampoco la configuras.
Tipos por tenant (la mejor DX) — hablala codegen
Sin más, query() acepta cualquier slug y properties es genérico. Para tener
autocompletado de tus objetos/atributos e inferencia del tipo de respuesta,
genera los tipos de tu tenant con el CLI de la plataforma
(@hablala/cli) — el patrón estándar
de type-generation desde el esquema:
HABLALA_TOKEN=pk_… npx @hablala/cli codegen
# → escribe hablala.types.ts con `interface HablalaSchema`Luego parametriza el cliente con el esquema generado:
import { createHablalaClient } from "@hablala/client";
import type { HablalaSchema } from "./hablala.types";
const hablala = createHablalaClient<HablalaSchema>({
token: "pk_…", // la org sale del token; endpoint opcional
});
const { data } = await hablala.query({
object: "obra", // ← autocompleta los objetos de tu tenant
select: ["nombre", "estado"], // ← autocompleta los atributos de 'obra'
where: [{ attr: "estado", op: "eq", value: "en_progreso" }], // ← 'attr' tipado
});
data[0].properties.nombre; // ✅ string — inferido de tu dataType
data[0].properties.no_existe; // ✗ error de compilaciónRegenera hablala.types.ts cada vez que cambies tu modelo de datos (idealmente en CI o
en un script postinstall/predev). El fichero está marcado como generado; no
lo edites a mano. Puedes pasarle prettier si quieres.
Opciones del CLI: --token (requerido; o HABLALA_TOKEN),
--endpoint (opcional; o HABLALA_ENDPOINT) y --out. No hay --org: la
organización se infiere del token, igual que en el cliente.
Tokens: público vs privado (⚠️ léelo)
Una API key es la credencial de máquina de tu app — nunca un usuario. Es el principal
public del canal público. Hay dos posturas (canónico Stripe/Clerk):
| | Prefijo | Dónde va | Exposición |
| --------------- | ------- | ---------------------------------- | ----------------- |
| Publishable | pk_… | En el browser / bundle del cliente | Seguro de exponer |
| Secret | sk_… | Solo en el servidor (loader SSR) | Secreto |
El error más común: meter la secret key en el bundle del cliente. No lo hagas. La
sk_…solo debe leerse de env vars server-side (process.env.…), nunca de una envVITE_…/pública. Si se filtra, revócala en el panel y genera otra.
La distinción publishable/secret es de exposición, no de permisos: ambas tienen el mismo
alcance. Exponer la pk_… es seguro porque no da acceso a nada por sí misma — lo que se puede
leer o escribir lo decide el ABAC (default-deny): solo el contenido publicado y solo los objetos
que la app abre explícitamente al principal public. La key no es una llave maestra; es una
identidad acotada por policies.
El end-user session token (sess_…) es distinto: es la sesión de un usuario final (no de la
app), la emite auth.login/register, y el cliente la gestiona por ti (ver Cuentas de comprador).
No lo pongas en env vars: nace y muere con la sesión del visitante.
Uso con React Router (SSR + hidratación)
getHablalaClient elige la key correcta según el entorno: la secret key en el
servidor, la publishable key en el cliente. Escribe el loader una vez y corre en
ambos lados sin filtrar la secret key.
// app/lib/hablala.ts — compartido por loaders cliente y servidor
import { getHablalaClient, type HablalaConfig } from "@hablala/client";
const config: HablalaConfig = {
publishableKey: import.meta.env.VITE_HABLALA_PUBLISHABLE_KEY, // seguro en el bundle
// endpoint opcional (default https://api.hablala.com). La org sale de la key.
// La secret key NO lleva prefijo VITE_ → nunca acaba en el bundle del cliente.
secretKey: typeof process !== "undefined" ? process.env.HABLALA_SECRET_KEY : undefined,
};
export const getClient = () => getHablalaClient(config);// app/routes/obras.tsx
import { getClient } from "~/lib/hablala";
export async function loader() {
const hablala = getClient(); // secret key en SSR, publishable al navegar en cliente
return hablala.query({
object: "obra",
where: [{ attr: "estado", op: "eq", value: "en_progreso" }],
order_by: [{ attr: "fecha_entrega", dir: "asc" }],
});
}
export default function Obras({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
// Tu diseño, tu código. El SDK no opina.
return (
<ul>
{loaderData.data.map((obra) => (
<li key={obra.id}>{String(obra.properties.nombre)}</li>
))}
</ul>
);
}Consultas
La consulta lógica usa slugs de tu diccionario (los que definiste en tu modelo de datos). Campos disponibles:
object(requerido) — slug del objeto raíz.select— slugs a devolver (default: todos los permitidos por las policies).where— condiciones AND. Operadores:eq, neq, gt, gte, lt, lte, in, contains, is_null, is_not_null. Para OR:{ or: [ ...condiciones... ] }.include— expansión de relaciones (un nivel).order_by—[{ attr, dir: "asc" | "desc" }].limit(máx 200),offset.paginate+cursor— paginación keyset (ver abajo).semantic— búsqueda vectorial:{ query, top_k }(combinable conwhere).
Paginación (keyset)
Manual, con cursor:
let cursor: string | null | undefined;
do {
const page = await hablala.query({ object: "obra", paginate: true, cursor });
render(page.data);
cursor = page.nextCursor; // null en la última página
} while (cursor);O automática con queryAll (async iterator — no llevas el cursor a mano):
for await (const page of hablala.queryAll({ object: "obra" })) {
for (const obra of page) render(obra);
}Búsqueda semántica
await hablala.query({
object: "obra",
where: [{ attr: "estado", op: "eq", value: "en_progreso" }], // filtra primero
semantic: { query: "torres residenciales cerca de la costa", top_k: 5 }, // rankea después
});Errores
query() lanza HablalaQueryError con un code accionable y, cuando aplica,
una sugerencia por sinónimos:
import { HablalaQueryError } from "@hablala/client";
try {
await hablala.query({ object: "obra", where: [{ attr: "nombre", op: "eq", value: "x" }] });
} catch (err) {
if (err instanceof HablalaQueryError) {
err.code; // "unknown_attribute" | "forbidden" | "unauthorized" | …
err.didYouMean; // ["titulo"] ← sugerencia por synonyms
err.status; // status HTTP (0 si fue error de red)
err.docUrl; // doc de la clase de error (RFC 9457 `type`) — ábrela para resolver
}
}Códigos: unknown_object, unknown_attribute, unknown_relationship,
type_mismatch, forbidden, limit_exceeded, unauthorized, not_found,
invalid_query, rate_limited, network_error, unknown.
En vez de comparar códigos a mano, usa los helpers semánticos:
if (err.isAuthError()) rotateToken(); // 401
if (err.isForbidden()) showNotPublished(); // 403 / ABAC
if (err.isRateLimited()) wait(err.retryAfter); // 429 (retryAfter en segundos)
if (err.isQueryError()) fixQuery(); // slug/tipo/límite
if (err.isNetworkError()) retryLater(); // sin respuesta HTTPUn forbidden casi siempre significa que no hay una policy allow read que
exponga ese contenido al principal public — revisa que esté publicado y
que exista la policy ABAC correspondiente en tu tenant.
Escritura (formularios, reseñas, capturas)
La tienda no solo lee: escribe de vuelta a la plataforma. Un formulario de contacto crea un
lead; una reseña, un product_review. Verbos genéricos con el objeto como argumento (el
estándar para un backend schemaless: el patrón de Sanity):
await hablala.create("product_review", { rating: 5, body: "Excelente" }); // → { id, properties }
await hablala.update("product_review", "rec_…", { body: "Editado" }); // merge parcial (PATCH)
await hablala.delete("product_review", "rec_…"); // soft-deleteNada es escribible por defecto. La escritura pública la gobierna el ABAC: la marca abre cada objeto explícitamente con una policy
allow/createpara el principalpublic(en su Account as Code). Sin ella, recibes un403 forbidden— así una app no puede escribir en objetos que no le corresponden (p. ej.productocustomer). Es la misma disciplina que la lectura "publicada", aplicada a la escritura.
Cuentas de comprador (auth + me)
Para "mis pedidos", dejar una reseña como cliente, o un checkout con cuenta, el usuario final
inicia sesión. El cliente es stateful: tras login/register guarda el end-user session
token (sess_…) y lo auto-adjunta — no lo re-pasas (patrón de Medusa/Saleor/commercetools).
// Registro (crea el record del usuario final + su credencial) e inicio de sesión, en un paso:
await hablala.auth.register({
email: "[email protected]",
password: "…",
properties: { first_name: "María" }, // properties adicionales del record del usuario final
});
// O login de un usuario final existente:
const session = await hablala.auth.login({ email: "[email protected]", password: "…" });
session.endUserId; // "rec_…" (el record del usuario final)
// Ya identificado: `me.*` va al canal auto-acotado — solo SUS records.
const { data: misPedidos } = await hablala.me.query({ object: "order" });
await hablala.me.create("product_review", { rating: 5, body: "Volveré a comprar" });
await hablala.me.update("customer", session.endUserId!, { phone: "+52…" });
await hablala.auth.logout(); // revoca la sesión en el server y la borra del storageme.* está auto-acotado por el backend: el ABAC solo deja ver/tocar los records del propio
usuario final (por principal.end_user_id), sin que tú filtres nada en el cliente. Un usuario final
nunca ve los pedidos de otro.
Sesión anónima → merge al identificarse
El carrito/actividad antes de que el usuario final se identifique usa una sesión anónima; al
hacer login/register se fusiona (merge) con su cuenta — el carrito no se pierde:
await hablala.auth.anonymous(); // token `sess_…` sin usuario final (el carrito de invitado)
// … el visitante añade cosas …
await hablala.auth.login({ email, password }); // la sesión anónima se identifica (merge)Checkout nativo (me.checkout)
Hablalá es la fuente de verdad del pedido (no espejo de una tienda externa): el checkout es una
transacción de dominio atómica server-side — valida stock por variante, numera el pedido
(#1001), crea el order + sus renglones con snapshots de precio, y decrementa el inventario, todo
en una transacción. El comprador sale de la sesión (sess_…), no del cuerpo.
El flujo completo: carrito client-side → (sesión anónima o cuenta) → me.checkout.
// El carrito lo llevas tú en el cliente (localStorage, un store, lo que sea).
// Al pagar, aseguras una sesión (anónima basta) y llamas al checkout:
await hablala.auth.anonymous(); // o auth.login() si el comprador tiene cuenta
const order = await hablala.me.checkout(
{
items: [
{ variantId: "rec_variante_talla_m", quantity: 2 },
{ variantId: "rec_variante_talla_l", quantity: 1 },
],
currency: "MXN",
},
{ idempotencyKey: cartId }, // header `Idempotency-Key` (Stripe): reintentos → un solo pedido
);
order.id; // "rec_…"
order.properties.order_number; // "#1001"
order.properties.financial_status; // "pending"
order.lineItems; // un renglón por artículo, con snapshots de título/sku/precioSi una variante se quedó sin stock, el checkout responde 409 y nada se crea (rollback total). El SDK lo expone como un error accionable:
try {
await hablala.me.checkout({ items, currency: "MXN" });
} catch (err) {
if (err instanceof HablalaQueryError && err.isOutOfStock()) {
// Refresca disponibilidad y marca "Agotado" en el line item; el pedido NO se creó.
}
}Dónde se guarda la sesión (storage)
Por defecto la sesión vive en memoria (una por instancia del cliente — ideal en SSR: cada
request su comprador). Para persistirla, inyecta un storage:
import { createHablalaClient, createLocalStorageStorage } from "@hablala/client";
// Browser SPA: la sesión sobrevive recargas.
const hablala = createHablalaClient({ token: "pk_…", auth: { storage: createLocalStorageStorage() } });AuthStorage es un contrato mínimo (get/set/clear); en SSR con cookies, pasa un adaptador propio
sobre tus cookies. auth.login() devuelve también el token por si prefieres gestionarlo tú.
Resiliencia: reintentos y hooks
Por defecto el cliente reintenta 2 veces los fallos transitorios (red, 429,
5xx) con backoff exponencial + jitter, respetando Retry-After. Configúralo:
const hablala = createHablalaClient({
// ...
retries: 3, // 0 para desactivar
debug: true, // loguea cada request/response por console.debug
hooks: {
beforeRequest: (req) => new Request(req, { headers: { ...h(req), "x-app": "web" } }),
afterResponse: (res, req) => metrics.record(req.url, res.status),
},
});Ejemplo end-to-end: constructora
Modelas tu modelo de datos en Hablalá (sin migraciones): objeto obra con atributos
nombre, presupuesto (number, indexed), estado (select), fecha_entrega
(date), relación obra → cliente_obra. Marcas como publicadas las obras a
mostrar (una policy ABAC allow read para public filtrada por
estado/visibilidad). Luego, en tu frontend:
export async function loader() {
const hablala = getClient();
const [obras, clientes] = await Promise.all([
hablala.query({
object: "obra",
where: [{ attr: "estado", op: "eq", value: "en_progreso" }],
include: [{ relationship: "cliente_obra", select: ["nombre"] }],
order_by: [{ attr: "fecha_entrega", dir: "asc" }],
limit: 20,
}),
hablala.query({ object: "cliente_obra", order_by: [{ attr: "nombre", dir: "asc" }] }),
]);
return { obras: obras.data, clientes: clientes.data };
}El diseño de la página lo escribes tú. Hablalá decide qué dice; tu código decide cómo se ve.
API
createHablalaClient<S>(options): HablalaClient<S>— cliente con un token fijo. Parametrízalo con elHablalaSchemadehablala codegenpara tipos por tenant.getHablalaClient<S>(config): HablalaClient<S>— cliente con postura por entorno (secret key en SSR, publishable en cliente).hablalaLoader(config, run)— azúcar: crea el cliente y ejecutarun(client).client.query(logicalQuery)— ejecuta una consulta (respuesta inferida si hayS).client.queryAll(logicalQuery)— async iterator que auto-pagina.client.create(object, properties)/client.update(object, id, properties)/client.delete(object, id)— escritura pública (canalpublic, gobernada por ABAC).client.auth.register(input)/login(input)/anonymous()/logout()/token()— cuentas de usuario final (stateful; guarda elsess_…en elstorageinyectable).client.me.query/create/update/delete— superficie del usuario final identificado, auto-acotada por el backend a sus propios records (/me/*).client.me.checkout(input, { idempotencyKey? })— checkout nativo atómico: valida stock, numera, crea el pedido + renglones y decrementa inventario en una transacción server-side (/me/checkout).createMemoryStorage()/createLocalStorageStorage(key?)— adaptadores deAuthStoragepara la sesión. (memoryStorage/localStorageStorageson alias deprecados.)HablalaQueryError— error tipado concode/didYouMean/status/retryAfter/docUrly helpersisAuthError()/isForbidden()/isRateLimited()/isQueryError()/isNetworkError().
CLI: npx @hablala/cli codegen — genera hablala.types.ts (introspección del
esquema de tu tenant). El bin hablala vive en @hablala/cli (un solo CLI de
marca, con subcomandos); este paquete es librería pura.
Todos los tipos crudos del contrato (paths, components, operations) se
reexportan por si los necesitas.
