@hablala/client
v0.5.0
Published
SDK headless de Hablalá — cliente tipado de la Data API + helpers de loader para React Router.
Maintainers
Readme
@hablala/client
SDK headless de Hablalá — el cliente oficial de la Data API para tu frontend.
Hablalá es un CMS + Data API: tú modelas tus objects (tu modelo de datos) y editas contenido dentro de Hablalá; el frontend lo escribes tú, en tu propio proyecto React Router. Este paquete te da las "baterías incluidas" para conectarte: un cliente tipado sobre la Data API y helpers de loader.
Cero componentes de UI. El diseño es 100% tuyo. Este SDK solo mueve datos.
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: "sfpk_…", // storefront token — 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 (público/privado) también sale del prefijo del token, 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_STOREFRONT_TOKEN=sfpk_… 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: "sfpk_…", // 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_STOREFRONT_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)
Un storefront token es una credencial read-only de máquina — nunca un usuario. Solo lee el contenido que tus policies ABAC marcan como publicado. Hay dos posturas:
| | Prefijo | Dónde va | Exposición |
| ----------- | -------- | ---------------------------------- | ----------------- |
| Public | sfpk_… | En el browser / bundle del cliente | Seguro de exponer |
| Private | sfpr_… | Solo en el servidor (loader SSR) | Secreto |
El error más común: meter el token privado en el bundle del cliente. No lo hagas. El
privatetoken solo debe leerse de env vars server-side (process.env.…), nunca de una envVITE_…/pública. Si se filtra, revócalo en el panel y genera otro.
Ambos son read-only, así que exponer el public no da acceso de escritura ni a
contenido no publicado. La distinción es de exposición, no de permisos.
Uso con React Router (SSR + hidratación)
getHablalaClient elige el token correcto según el entorno: privado en el
servidor, público en el cliente. Escribe el loader una vez y corre en
ambos lados sin filtrar el token privado.
// app/lib/hablala.ts — compartido por loaders cliente y servidor
import { getHablalaClient, type HablalaConfig } from "@hablala/client";
const config: HablalaConfig = {
publicToken: import.meta.env.VITE_HABLALA_PUBLIC_TOKEN, // seguro en el bundle
// endpoint opcional (default https://api.hablala.com). La org sale del token.
// El privado NO lleva prefijo VITE_ → nunca acaba en el bundle del cliente.
privateToken: typeof process !== "undefined" ? process.env.HABLALA_PRIVATE_TOKEN : undefined,
};
export const getClient = () => getHablalaClient(config);// app/routes/obras.tsx
import { getClient } from "~/lib/hablala";
export async function loader() {
const hablala = getClient(); // privado en SSR, público 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)
}
}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 storefront — revisa que esté publicado y
que exista la policy ABAC correspondiente en tu tenant.
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 storefront 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 (privado en SSR, público 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.HablalaQueryError— error tipado concode/didYouMean/status/retryAftery 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.
