@userkit/js
v0.7.0
Published
The UserKit client for the browser: contact sessions, hosted sign-in, social sign-in, and the short-lived JWT your backend verifies.
Maintainers
Readme
@userkit/js
O cliente do UserKit para o navegador. Sessão de contato, autenticação
hospedada, login social, magic link, boot e o JWT curto que o backend do
desenvolvedor verifica sozinho.
Framework-agnóstico de propósito: o @userkit/react é construído sobre este
pacote, e um projeto em Svelte, Vue ou nenhum framework usa exatamente o mesmo
objeto.
npm install @userkit/jsVersão
0.3.0. Pré-1.0 de propósito: a superfície ainda cresce, então um minor pode quebrar compatibilidade. Fixe a versão se isso importar para você. O pacote é ESM-only —import, nãorequire(); um Node antigo ou um Jest em CJS puro precisam de transformação. As mudanças estão emCHANGELOG.md.
Uso
import { createClient } from "@userkit/js";
const userkit = createClient({ publishableKey: "uk_pk_live_…" });
// Uma vez, no carregamento: restaura a sessão da visita anterior.
await userkit.load();
// A UI segue o estado. O listener é chamado na hora da inscrição também.
userkit.subscribe((state) => {
if (state.status === "loading") return renderSkeleton();
state.contact ? renderApp(state.contact) : renderSignIn();
});status começa em loading de propósito. Um botão de entrar renderizado como
"deslogado" antes do load() resolver pisca a cada recarga da página — que é o
defeito mais visível de um SDK de autenticação.
Entrar
await userkit.signUp({ email, password, name }); // sempre resolve: 202
await userkit.signIn({ email, password }); // lança invalid_credentials
await userkit.verifyEmail(tokenDaURL); // já entra com a pessoa
await userkit.signOut();Login social
Duas chamadas, porque o redirecionamento sai do processo — mas o state fica
guardado aqui, então a sua página de callback não precisa carregar nada:
// Na tela de login:
await userkit.signInWithOAuth({
provider: "google",
redirectUri: "https://app.example.com/auth/callback",
});
// Na página de callback:
await userkit.completeOAuth();completeOAuth() lê code e state da URL atual e confere o state contra o
que este navegador guardou: um callback que não veio de um fluxo iniciado aqui é
recusado antes do código ser gasto.
O JWT
const token = await userkit.getToken();
await fetch("/api/minha-rota", { headers: { Authorization: `Bearer ${token}` } });Cinco minutos, renovado trinta segundos antes de vencer e compartilhado entre
chamadas concorrentes — chamar isso a cada requisição é o uso pretendido. Seu
backend valida contra GET /v1/jwks/{publishable_key}, sem nos chamar.
Os times do contato
const teams = await userkit.listCustomerMemberships();
userkit.setActiveCustomer(teams[1].id); // não escreve nada
const { members, invitations } = await userkit.listCustomerMembers();Trocar de time é navegação: nada é gravado aqui nem no servidor — a próxima
requisição leva outro X-Customer-Id, e a associação por trás dele é quem decide
o que ela pode fazer. A escolha fica em memória de propósito: guardada, duas abas
passariam a dividir um cursor só, que é exatamente o que a API abandonou ao não
guardar um active_customer_id. Se você quer que ela sobreviva a um recarregamento,
coloque o time na sua rota e chame setActiveCustomer no carregamento.
Sem escolha nenhuma, a API responde pela associação mais antiga do contato — o que faz a primeira renderização estar certa antes de alguém escolher.
O que o plano do time inclui
const grant = await userkit.getEntitlements();
if (grant.features["api_access"]?.enabled) { … }Resolve para o time ativo, exatamente como as outras chamadas de time. Três
respostas valem um branch: 404 é sessão anônima (não pertence a nenhum
customer — ofereça criar um time), 403 unverified_session é identidade nunca
provada, e 503 entitlements_unavailable é "pergunte de novo" — nunca trate
como um plano vazio.
Eventos
userkit.track("checkout_started", { plan: "pro" });Síncrono, e nunca uma requisição: os eventos entram numa fila durável
(localStorage) e saem em lote — por intervalo, quando o navegador volta a
ficar online, e no pagehide com keepalive. Um evento só sai da fila depois
do 202 da API, e o id (UUIDv7, cunhado na chamada) é o que faz uma reentrega
contar uma vez só. A fila tem teto de 500: estourou, os mais antigos são
descartados — comportamento recente vale mais do que o que uma indisponibilidade
perdeu horas atrás.
Nomes são [a-z0-9_.:-]{1,64}; um nome fora disso, ou com o prefixo $
(reservado à plataforma), é recusado com um throw na hora — um evento
descartado em silêncio só seria descoberto num dashboard, tarde demais.
Visitante anônimo pode rastrear: o anonymous_id do dispositivo sempre
acompanha o lote, e uma sessão ativa resolve os eventos para o contato.
Feature flags
await userkit.boot(); // decide as flags desta pessoa
if (userkit.isEnabled("new_checkout")) { … }isEnabled é síncrono, e isso é o contrato: uma UI que esperasse uma
promise renderizaria a ausência do recurso primeiro e o recurso um tick depois,
e uma flag que pisca a cada montagem é pior do que uma flag desligada.
Quem decide é o boot() — segmento, rampa percentual, tudo — no round trip que
a sessão já custou. O que mantém isso atual é um poll de
GET /v1/flags/{publishable_key}, um documento público e cacheável que carrega
só key e enabled, sem o alvo. Daí a assimetria que é o desenho inteiro: o
documento pode desligar uma flag e nunca ligar — enabled: true ali diz que
a chave está ligada, não que esta pessoa está na audiência. Desligar chega à
página em ~30 segundos; alargar uma rampa vale no próximo boot. A direção
urgente é o desligar.
O poll roda só com a página visível, e volta a perguntar assim que a aba volta em vez de esperar o intervalo que dormiu. Falha de rede mantém o último estado: um kill switch que abre quando a rede cai é o oposto do que ele existe para fazer.
Onboarding, changelog, pesquisas e sugestões
const listas = await userkit.getChecklist();
const { unread, since } = await userkit.getChangelogUnread();
const [pendente] = await userkit.listPendingSurveys();
const { posts } = await userkit.getFeedbackBoard();Quatro superfícies, e o que não existe em cada uma é a parte que importa.
Não há como marcar um passo do checklist: um passo é satisfeito por um evento
ter acontecido ou por um direito do plano estar valendo, então a única escrita
ali é dismissChecklist(id) — que fecha uma lista, por lista, e não conclui
nada. Um ambiente tem várias, e uma marca na pessoa faria a lista lançada no mês
que vem nascer escondida para quem fechou a anterior. Não há como pedir uma pesquisa: quem decide é a API, a
partir de um gatilho, uma audiência e um intervalo por pessoa. E não há como
ler o peso de um voto por receita — isso existe no painel e em lugar nenhum
mais.
O since do badge é quando este contato chegou: um badge que abre em 47 no
primeiro dia é um badge que a pessoa dispensa uma vez e nunca mais olha.
answerSurvey manda o score só quando ele foi escolhido — zero é uma nota
real de NPS, a mais dura delas, e nunca deve virar "não respondeu".
As notificações dentro do produto
const { notifications, unread } = await userkit.listNotifications();
await userkit.listNotifications({ unreadOnly: true });
await userkit.markNotificationRead(notifications[0].id);
await userkit.markAllNotificationsRead();O feed é lido dentro do time ativo: as mensagens daquele time mais as que
foram endereçadas à própria pessoa (customer_id: null), que aparecem em
qualquer time dela. setActiveCustomer muda a leitura, e markAllNotificationsRead
limpa exatamente o que a tela mostrava — quem zera o badge num time não marca
como lidas as mensagens de outro.
unread é a conta de tudo que não foi lido, nunca o tamanho da página — a
página para em 50, então um badge tirado dela erra exatamente para quem tem
mais. E não existe método que crie uma notificação: quem escreve é o backend
do desenvolvedor, em POST /v1/notifications com a chave uk_sk_, porque uma
rota alcançável pelo navegador deixaria qualquer página dizer qualquer coisa a
qualquer usuário dele.
url é para onde a notificação leva, ou null quando ela não leva a lugar
nenhum. Ou um caminho começando em /, ou um endereço http(s) completo — a API
recusa qualquer outra forma na escrita, esquema que executa incluído, então dá
para colocar num href sem sanitizar de novo.
Nada aqui passa por consentimento, e isso é construção e não decisão de tela: a
categoria que a API grava é um valor que notification_preferences não consegue
guardar, então não há linha que desligue um aviso de "sua fatura falhou".
Quais e-mails esta pessoa aceita receber
const preferencias = await userkit.getNotificationPreferences();
await userkit.updateNotificationPreferences([{ category: "marketing", opted_out: true }]);A resposta é sempre o catálogo inteiro, não as decisões guardadas: a ausência de linha é o consentimento, então uma tela que mostrasse só o que existe estaria vazia para todo mundo que nunca a abriu.
E-mail transacional não está na lista, e não é omissão — não existe categoria que o nomeie. Confirmação de endereço, redefinição de senha, recibo e aviso de segurança continuam chegando depois de a pessoa desligar tudo aqui, e vale dizer isso na tela em vez de deixar a inferência acontecer. Todo e-mail não transacional carrega, além disso, um link de descadastro que funciona sem sessão nenhuma e não expira: quem não consegue sair marca como spam, e aí quem paga é o domínio.
Erros
import { isUserKitError } from "@userkit/js";
try {
await userkit.signIn({ email, password });
} catch (error) {
if (isUserKitError(error) && error.code === "invalid_credentials") { … }
}Ramifique no code, nunca na message: o código é contrato estável, a mensagem
é uma frase para gente e pode mudar. UserKitErrorCode é a união dos códigos
conhecidos — error.code autocompleta — e continua aberta para os que a API
ganhar depois. Uma chamada que não responde em 15 s falha com timeout
(timeoutMs na criação do cliente muda o teto; 0 desliga).
Onde a sessão fica, e o modo proxy
No modo padrão (direct) o token uk_ct_… fica em localStorage, por trinta
dias, e qualquer script na sua origem consegue lê-lo — é o custo de não precisar
de servidor. Se você tem um servidor, prefira mode: "proxy": as rotas na
sua própria origem guardam o token num cookie httpOnly, e o navegador nunca o
vê. É o arranjo que @userkit/nextjs monta para você.
Um meio-termo sem servidor é trocar o storage: sessionStorage termina com a
aba, e a interface é de três linhas.
const userkit = createClient({
publishableKey: "uk_pk_live_…",
storage: {
get: (key) => sessionStorage.getItem(key),
set: (key, value) => sessionStorage.setItem(key, value),
remove: (key) => sessionStorage.removeItem(key),
},
});Ciclo de vida
Um cliente por página. Onde isso não vale — StrictMode, hot reload, um
micro-frontend que monta e desmonta, um teste entre dois casos — destroy()
desarma todo timer e listener que o cliente instalou; a sessão e a fila de
eventos ficam no storage para o próximo. A sessão acompanha as outras abas da
mesma origem: sair numa é sair em todas, na hora.
Testes
@userkit/js/testing traz o fakeFetch() e as fixtures (fakeContact,
fakeAuthResult, …) que a própria suíte deste pacote usa — instale-o no
beforeEach e responda o que cada chamada deve receber.
O que não está aqui
Um método de SDK para uma rota que não existe é promessa, não funcionalidade.
Por essa regra não há nada aqui que edite nome, e-mail ou avatar de um contato:
/v1/contact/* não tem esse endpoint.
