tRPC - ciclo de desarrollo súper rápido para aplicaciones fullstack en TypeScript
Estamos construyendo un cliente y servidor tRPC con consultas, mutaciones, autenticación y suscripciones. La autenticación para websocket puede ser complicada y en este caso lo es, por lo que se presentan tres enfoques para resolver este problema.
Daniel Gustaw
• 16 min read
Hoy aprendí tRPC y me enamoré ❤️ instantáneamente, decidiendo reescribir el proyecto que actualmente estoy desarrollando a este marco.
En pocas palabras, de qué se trata: 1. Puedes desarrollar un esquema como en gRPC 2. Pero solo estás limitado a TypeScript (el soporte para Rust está en progreso) 3. En lugar de protobuf, que es difícil de leer/debuguear, tienes tipos livianos generados a partir de tus validadores (como zod) y resolutores
Finalmente, obtienes el ciclo de desarrollo fullstack más rápido que he visto y solo puedo compararlo con Ruby on Rails.
Ejemplo mínimo de tRPC con consulta por http
Déjame mostrarte un proyecto mínimo utilizando esta pila.
Empezaremos desde 2 carpetas:
- cliente
- servidor
En cliente
tenemos que instalar @trpc/client
y en servidor
instalamos @trpc/server
y zod
.
En servidor/index.ts
estamos creando el servidor con el esquema generado a partir de nuestro código.
import {initTRPC} from '@trpc/server';
import {createHTTPServer} from '@trpc/server/adapters/standalone';
import {z} from 'zod'
export type AppRouter = typeof appRouter;
const t = initTRPC.create();
const publicProcedure = t.procedure;
const router = t.router;
const appRouter = router({
greet: publicProcedure
.input(z.string())
.query(({input}) => ({greeting: `hello, ${input}!`})),
});
createHTTPServer({
router: appRouter,
}).listen(2022);
Mira la línea export type AppRouter
, esta línea es responsable de exportar el esquema para el cliente. Unas líneas más adelante definimos todas las rutas usando la función router
.
No solo hay query
, sino también mutation
y subscription
. Pero en nuestro ejemplo tenemos que mostrar un inicio mínimo, así que veamos el código del cliente.
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server';
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:2022',
}),
],
});
async function main() {
const result = await client.greet.query('tRPC');
// Type safe
console.log(result.greeting.toUpperCase());
}
void main();
Aquí está la importación de AppRouter
y lo estamos usando como tipo genérico para crear client
. Así que todos los:
- métodos del cliente
- argumentos de los métodos
- salidas de los métodos
tienen tipado fuerte.
Autenticación con tRPC
Agreguemos una mutación que solo puede ser realizada por un administrador. Para simplificar, omitiremos jwt / inicio de sesión / registro y consideraremos la situación en la que el cliente puede enviar el encabezado Authorization
con ABC
para autorizarlo.
Así que en esta parte aprenderás cómo agregar autorización, middleware y mutaciones.
Vamos a crear context.ts
en server
import {inferAsyncReturnType} from '@trpc/server';
import {CreateNextContextOptions} from '@trpc/server/adapters/next';
export async function createContext({req}: CreateNextContextOptions) {
return {
auth: req.headers.authorization === 'ABC'
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
Ahora podemos cambiar
const t = initTRPC.create();
a
import type { Context } from './context';
export const t = initTRPC.context<Context>().create();
Ahora esperamos que en context
podamos verificar si el usuario está autorizado.
También tienes que añadir createContext
a las opciones de createHTTPServer
, así que cambia:
createHTTPServer({
router: appRouter,
}).listen(2022);
a
import {createContext} from "./context";
createHTTPServer({
router: appRouter,
createContext
}).listen(2022);
Ahora tenemos 2 opciones. Podemos verificar auth
en el resolver.
secret: t.procedure.query(({ ctx }) => {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return {
secret: 'sauce',
};
}),
pero el mejor enfoque probablemente sea agregar esta verificación a un middleware llamado protectedProcedure
.
Es un poco más de código, pero nos da algunas ventajas.
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
auth: ctx.auth
}
});
});
const protectedProcedure = t.procedure.use(isAuthed);
Primero, podemos redefinir nuestro contexto, por ejemplo, encontrando al usuario en la base de datos y convirtiendo el id en un token para el conjunto completo de parámetros del usuario. Además, podemos reutilizar protectedProcedure
en todos los lugares sin repetir esta verificación cada vez.
Ahora hay un último paso final en el servidor: agregar una nueva ruta a las claves de argumento router
.
secret: protectedProcedure.mutation(() => "access granted")
En el cliente podemos usarlo de la siguiente manera:
const unauthorizedError = await client.secret.mutate();
console.log(unauthorizedError);
y veremos un hermoso error no autorizado como este
Para ser autorizado, podemos agregar encabezados en la definición del cliente.
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:2022',
headers: {
Authorization: 'ABC'
}
}),
],
});
Si lo dejara en esa forma, tendría que recrear el cliente con nuevos encabezados en cada cambio de encabezado. Afortunadamente, esta forma simple se puede mejorar y podemos escribir de esta manera:
const headers: Map<string, string> = new Map<string, string>();
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:2022',
headers: () => Object.fromEntries(headers)
}),
],
});
y decidir sobre la forma de los encabezados dinámicamente en tiempo de ejecución, por ejemplo, configurando Authorization
mediante
headers.set('Authorization', 'ABC');
Tiempo de tiempo real con suscripciones tRPC
En server
estamos instalando ws
.
npm i ws
npm i -D @types/ws
A router
le podemos agregar una nueva suscripción que nos dará tiempo cada segundo.
time: publicProcedure.subscription(() => {
return observable<Date>((emit) => {
// logic that will execute on subscription start
const interval = setInterval(() => emit.next(new Date()), 1000);
// function to clean up and close interval after end of connection
return () => {
clearInterval(interval);
}
})
})
Ahora tenemos que abrir el servidor websocket
, así que añadámoslo usando el código:
const wss = new ws.Server({
port: 3001,
});
const handler = applyWSSHandler({ wss, router: appRouter, createContext });
wss.on('connection', (ws) => {
console.log(`➕➕ Connection (${wss.clients.size})`);
ws.once('close', () => {
console.log(`➖➖ Connection (${wss.clients.size})`);
});
});
console.log('✅ WebSocket Server listening on ws://localhost:3001');
process.on('SIGTERM', () => {
console.log('SIGTERM');
handler.broadcastReconnectNotification();
wss.close();
});
Verifiqué en insomnia
que puedo conectar.
En la carga útil, utilicé un objeto con la forma descrita en la especificación jsonrpc
.
{
id: number | string;
jsonrpc?: '2.0';
method: 'subscription';
params: {
path: string;
input?: unknown; // <-- pass input of procedure, serialized by transformer
};
}
Así que conectemos nuestro cliente en TypeScript ahora.
Siguiendo la documentación oficial verás un error.
ReferenceError: WebSocket is not defined
porque createWSClient
asume que opera en el navegador, pero estamos usando el cliente de nodo en este ejemplo.
Para solucionarlo tenemos que instalar ws
y asignarlo al alcance global
, pero si tu cliente vive en el navegador, puedes omitir este paso.
npm i ws
npm i -D @types/ws
Ahora puedes crear wsClient
const WebSocket = require('ws');
const wsClient = createWSClient({
url: `ws://localhost:3001`,
WebSocket: WebSocket,
});
úsalo envolviéndolo en un enlace
const client = createTRPCProxyClient<AppRouter>({
links: [
wsLink({
client: wsClient
}),
],
});
y finalmente suscríbete para ver la serie de Fechas
client.time.subscribe(undefined, {
onData: (time) => {
console.log(time)
}
})
Desafortunadamente, tienes que eliminar nuestra mutación secreta.
await client.secret.mutate();
hacerlo funcionar.
Falta de documentación - autenticación de websocket en tRPC
Ahora nos enfrentamos al problema de proporcionar autenticación a websocket, pero probablemente sepas que los websockets puros no soportan encabezados http. Puedes pasarlos en la solicitud http de handshake que actualizará el protocolo a websocket. Los detalles están descritos en el RFC 6455
RFC 6455: El Protocolo WebSocket
En proyectos más maduros como apollo server puedes ver que la solicitud de actualización se usa para pasar información de autenticación, pero desafortunadamente ahora tRPC no lo soporta.
De todos modos, puedes dividir tu cliente en partes http y websocket.
const client = createTRPCProxyClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({
client: wsClient
}),
false: httpBatchLink({
url: 'http://localhost:2022',
headers: () => Object.fromEntries(headers)
}),
}),
],
});
Probablemente la mayoría de las operaciones serán operaciones http, por lo que puedes usar el mecanismo de autenticación descrito anteriormente para consultas y mutaciones. En caso de websocket, puedes usar la carga útil para pasar el token ahora o usar el truco que describo a continuación.
Para darte más contexto, hay un problema abierto:
feat: Autenticación por Websocket · Issue #3955 · trpc/trpc
Tema interesante pero engañoso en stackoverflow
Encabezados HTTP en la API de cliente Websockets
La respuesta con más votos es incorrecta, porque no tiene en cuenta el apretón de manos. Y ws
lo implementa como tercer argumento, pero no puedes encontrarlo en el README.md oficial.
En el código de trpc
este tercer argumento se omite
trpc/wsLink.ts en main · trpc/trpc
No puedes usar Sec-WebSocket-Key
porque ws
lo sobrescribe con un hash aleatorio.
ws/websocket.js en master · websockets/ws](https://github.com/websockets/ws/blob/master/lib/websocket.js#L717-L723)
y perder trpc
pierde esta información.
Hay tres enfoques para resolver este problema.
- pasar el encabezado de autenticación al apretón de manos (fácil, pero restrictivo y no práctico)
- construir un mapa entre los ids de conexión y estos tokens en el servidor (tiene fallas pero funciona)
- pasar el token a cualquier suscripción en la carga útil (menos elegante pero más escalable)
Escenario 1: Conocemos el token de autenticación antes de crear el cliente
Este es un escenario que es extremadamente fácil de implementar, pero no práctico. Lo presento solo porque no requiere cambios en el backend y será nuestra prueba de concepto que utilizaremos para mejorar en el siguiente paso.
Construyamos tu Proxy que agregará encabezados de todos modos.
const WebSocket = require('ws');
const WebSocketProxy = new Proxy(WebSocket, {
construct(target, args) {
return new target(args[0], undefined, {
headers: Object.fromEntries(headers)
});
}
})
Este objeto usará los encabezados definidos anteriormente como un mapa en la parte sobre autorización.
const headers: Map<string, string> = new Map<string, string>();
args[0]
será tu URL del servidor, y undefined es para el protocolo, no tienes que preocuparte por eso. De todos modos, estaba undefined/saltado.
Pero tenemos que establecer el encabezado mediante
headers.set('Authorization', 'ABC');
antes de llamar a createWSClient
.
Ahora puedes usar WebSocketProxy
en lugar de la implementación original de Websocket
const wsClient = createWSClient({
url: `ws://localhost:3001`,
WebSocket: WebSocketProxy,
});
El cliente solo puede tener wsLink.
const client = createTRPCProxyClient<AppRouter>({
links: [
wsLink({
client: wsClient
}),
],
});
O ser dividido en partes http y websocket.
const client = createTRPCProxyClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({
client: wsClient
}),
false: httpBatchLink({
url: 'http://localhost:2022',
headers: () => Object.fromEntries(headers)
}),
}),
],
});
En el lado del servidor no necesitamos cambios, pero haremos solo una mejora simple. Devolveremos el estado auth
de la suscripción time
.
time: publicProcedure.subscription(({ctx}) => {
return observable<{ date: Date, auth: boolean }>((emit) => {
// logic that will execute on subscription start
const interval = setInterval(() => emit.next({date: new Date(), auth: ctx.auth}), 1000);
// function to clean up and close interval after end of connection
return () => {
clearInterval(interval);
}
})
})
Ahora nuestra función main
del cliente será la siguiente.
async function main() {
const result = await client.greet.query('tRPC');
console.log(result.greeting.toUpperCase());
const secret = await client.secret.mutate();
console.log(secret);
client.time.subscribe(undefined, {
onData: ({auth, date}) => {
console.log(`I am ${auth ? 'auth' : 'not auth'} at ${date}`)
}
})
}
deberíamos ver que todas las solicitudes funcionan correctamente y que tenemos acceso al token en el contexto del websocket también.
Pero en un caso de uso real, comienzas la aplicación como un usuario no autenticado, que se autenticará por http y luego abrirá conexiones websocket para operar sobre ellas.
tRPC define la función tryReconnect
para wsLink
pero no la expone. Además, sería mejor poder autenticarse sin reconexión y tener un endpoint websocket especial dedicado a inicio de sesión.
Escenario 2: Nos autenticamos a través del contexto http y pasamos el resultado al contexto websocket
Comencemos con un diseño de alto nivel.
- Configuraremos nuestra
sec-websocket-key
que podremos guardar y reutilizar en el cliente. - Configuraremos un mapa de estas claves y estos estados de autenticación en el cliente.
- Permitiremos modificar este mapa utilizando solicitudes http con encabezados de autorización.
- Veremos que en la suscripción websocket, el estado de autenticación se puede obtener utilizando
keys
.
Configurando el encabezado con la identificación del cliente
Hay un pr abierto que permitirá anular sec-websocket-key
, pero ahora, usemos un nombre diferente. sec-websocket-id
parece ser genial.
Entonces, cuando nuestro cliente comienza (por ejemplo, el usuario ingresa a la url de nuestra página o en nuestro caso, el proceso de node comienza), necesitamos generar id. Me centraré en la implementación de node
, así que podemos usar crypto
.
Nuestras nuevas líneas en el cliente serán
import crypto from 'crypto';
const id = crypto.randomBytes(16).toString('hex')
headers.set('sec-websocket-id', id);
tienes que establecerlos antes de const wsClient = createWSClient({
.
Configuración del mapa de conexiones en el servidor
El punto crucial es que compartimos headers
para los enlaces http
y websocket
. Así que en el servidor createContext
esperamos ver este encabezado para todos los tipos de solicitudes: tanto las solicitudes http estándar como las solicitudes de actualización http que abrirán el websocket.
Nuestro context.ts
puede ser reescrito de esta manera:
import {inferAsyncReturnType} from '@trpc/server';
import {CreateNextContextOptions} from '@trpc/server/adapters/next';
const authState = new Map<string, boolean>();
export async function createContext({req}: CreateNextContextOptions) {
console.log(req.headers);
const auth = req.headers.authorization === 'ABC';
const id = req.headers['sec-websocket-id'];
authState.set(id, auth);
return {
auth: () => authState.get(id) ?? false,
id
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
así que ahora, no estamos verificando el estado de autenticación en la solicitud actual, sino el último valor guardado en Map
.
En nuestro escenario hay los siguientes eventos:
- consulta pública
- configuración de token
- mutación privada <– aquí estamos configurando la autenticación a verdadera
- suscripción de websocket <— aquí estamos usando el estado del mapa
Necesitamos ajustes muy pequeños en dos lugares. En la función isAuthed
necesitamos llamar a la autenticación.
const isAuthed = t.middleware(({next, ctx}) => {
if (!ctx.auth()) {
throw new TRPCError({code: 'UNAUTHORIZED'});
}
return next({
ctx: {
auth: ctx.auth
}
});
});
y en la suscripción tenemos que cambiar ctx.auth
a ctx.auth()
también
const interval = setInterval(() => emit.next({date: new Date(), auth: ctx.auth()}), 1000);
Verifiquemos si funciona para el cliente
Para lograr un efecto más dramático, podemos usar setTimeout
para retrasar la autenticación.
Nuestra función main
ahora tiene un formulario
async function main() {
const result = await client.greet.query('tRPC');
console.log(result.greeting.toUpperCase());
setTimeout(async () => {
headers.set('Authorization', 'ABC');
const secret = await client.secret.mutate();
console.log(secret);
}, 2000)
client.time.subscribe(undefined, {
onData: (ctx) => {
console.log(`I am ${ctx.auth ? 'auth' : 'not auth'} at ${ctx.date}`)
}
})
}
y en la consola veo
Escenario 3: Pasar el token en la entrada de suscripción
Para cubrir el problema completamente, estoy presentando el tercer enfoque anterior: pasar el token en la carga útil a la suscripción, en lugar de a las solicitudes de apretón de manos. Podemos modificar nuestro time
time: publicProcedure.input(
z.object({
token: z.string(),
}),
).subscription(({ctx, input}) => {
return observable<{ date: Date, ctx_auth: boolean, input_auth: boolean }>((emit) => {
// logic that will execute on subscription start
const interval = setInterval(() => emit.next({
date: new Date(),
ctx_auth: ctx.auth(),
input_auth: input.token === 'ABC'
}), 1000);
// function to clean up and close interval after end of connection
return () => {
clearInterval(interval);
}
})
})
y en el lado del cliente
client.time.subscribe({token: 'ABC'}, {
onData: (ctx) => {
console.log(`I am ${ctx.input_auth ? 'auth' : 'not auth'} at ${ctx.date}`)
}
})
Mejoras recomendadas
Si eres usuario de grpc y de manera similar a Funwithloops
en este tema de reddit:
https://www.reddit.com/r/node/comments/117fgb5/trpc_correct_way_to_authorize_websocket/
piensa en la autenticación de websocket. Deberías considerar esta publicación de blog como un boceto escrito por una persona que aprendió tRPC
hace unas pocas horas. En un entorno de producción, debes resolver el problema de compartir el estado guardado en authState
entre tus instancias de backend. Probablemente necesitarás redis para ello. Luego debes establecer los parámetros TX
para que no persistan estas claves infinitamente. Nos olvidamos del endpoint de cierre de sesión.
Redis para gestionar la autenticación disminuye el rendimiento en comparación con el jwt
puro, así que quizás un mejor diseño sería añadir la autenticación a la entrada de tu suscripción, lo cual, por otro lado, es menos legible y requiere más código extra.
Debes saber que trpc
no implementa la opción lazy
para el cliente de websocket que está disponible en Apollo y que simplificaría nuestro primer escenario que describí aquí.
Esta tecnología es super caliente, pero aún está en fase de desarrollo y este artículo puede quedar desactualizado pronto.
Si eres uno de los mantenedores de trpc
, puedes utilizar los conceptos presentados aquí en la documentación oficial o sugerirme un mejor enfoque para la autenticación de websocket en la sección de comentarios.
Other articles
You can find interesting also.
Nueva Identidad de Google en Nuxt 3
Nueva instalación de Google Identity para Nuxt 3 con verificación de credenciales en fastify.
Daniel Gustaw
• 7 min read
Cómo instalar MongoDB 6 en Fedora 37
Instalación de Mongodb 6 en Fedora Linux 37. El artículo muestra un fragmento faltante de la documentación oficial y dos pasos después de la instalación que se presentan de manera extremadamente simple en comparación con otras fuentes.
Daniel Gustaw
• 2 min read
Ruby on Rails - introducción rápida
Introducción a Ruby on Rails presentando CRUD, relaciones de base de datos, correo y comunicación por sockets web.
Daniel Gustaw
• 13 min read