tRPC - superszybki cykl rozwoju dla aplikacji fullstack w TypeScript
Budujemy klienta i serwer tRPC z zapytaniami, mutacjami, uwierzytelnianiem i subskrypcjami. Uwierzytelnianie dla websocketów może być skomplikowane i w tym przypadku także, dlatego przedstawione są trzy podejścia do rozwiązania tego problemu.
Daniel Gustaw
• 16 min read
Dzisiaj nauczyłem się tRPC i od razu się zakochałem ❤️, postanawiając przepisać projekt, nad którym obecnie pracuję, na ten framework.
W skrócie, co to jest: 1. Możesz rozwijać schemat jak w gRPC 2. Ale jesteś ograniczony tylko do typescript (wsparcie dla rusta w toku) 3. Zamiast protobuf, który jest trudny do odczytania/debugowania, masz lekkie typy generowane z Twoich walidatorów (takich jak zod) i resolverów
Ostatecznie zyskujesz najszybszy cykl rozwoju pełnego stosu, jaki kiedykolwiek widziałem, i mogę go porównać tylko z ruby on rails.
Minimalny przykład tRPC z zapytaniem przez http
Pozwól, że pokażę Ci minimalny projekt używający tego stosu.
Zaczniemy od 2 folderów:
- client
- server
W client
musimy zainstalować @trpc/client
, a w server
instalujemy @trpc/server
oraz zod
.
W server/index.ts
tworzymy serwer ze schematem wygenerowanym z naszego kodu.
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);
Zobacz na linijkę export type AppRouter
, która odpowiada za eksportowanie schematu dla klienta. Kilka linijek później definiujemy wszystkie trasy za pomocą funkcji router
.
Nie ma tylko query
, ale także mutation
i subscription
. Jednak w naszym przykładzie musimy pokazać minimalny zestaw startowy, więc przyjrzyjmy się kodowi klienta.
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();
Oto import AppRouter
, którego używamy jako typ ogólny do stworzenia client
. Zatem wszystkie:
- metody klienta
- argumenty metod
- wyniki metod
mają silne typowanie.
Uwierzytelnianie z tRPC
Dodajmy mutację, która może być wykonana tylko przez administratora. Aby uprościć, pominiemy jwt / logowanie / rejestrację i rozważymy sytuację, w której klient może wysłać nagłówek Authorization
z ABC
, aby się autoryzować.
W tej części dowiesz się, jak dodać autoryzację, middleware i mutacje.
Utwórzmy context.ts
w 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>;
Teraz możemy zmienić
const t = initTRPC.create();
do
import type { Context } from './context';
export const t = initTRPC.context<Context>().create();
Teraz oczekujemy, że w context
będziemy mogli sprawdzić, czy użytkownik jest autoryzowany.
Musisz również dodać createContext
do opcji createHTTPServer
, więc zmień:
createHTTPServer({
router: appRouter,
}).listen(2022);
do
import {createContext} from "./context";
createHTTPServer({
router: appRouter,
createContext
}).listen(2022);
Teraz mamy 2 opcje. Możemy sprawdzić auth
w resolverze.
secret: t.procedure.query(({ ctx }) => {
if (!ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return {
secret: 'sauce',
};
}),
lepszym podejściem jest prawdopodobnie dodanie tej kontroli do middleware o nazwie protectedProcedure
.
To nieco więcej kodu, ale daje nam pewne zalety
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);
Najpierw możemy zdefiniować nasz kontekst na nowo, tj. znajdowanie użytkownika w bazie danych i konwersja identyfikatora w tokenie na pełny zestaw parametrów użytkownika. Dodatkowo możemy ponownie wykorzystać protectedProcedure
we wszystkich miejscach bez powtarzania tej kontroli za każdym razem.
Teraz jest ostatni krok na serwerze: dodanie nowej trasy do kluczy argumentów router
.
secret: protectedProcedure.mutation(() => "access granted")
W kliencie możemy to użyć w następujący sposób:
const unauthorizedError = await client.secret.mutate();
console.log(unauthorizedError);
i zobaczymy piękny błąd nieautoryzowany, jak ten
Aby uzyskać autoryzację, możemy dodać nagłówki w definicji klienta.
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:2022',
headers: {
Authorization: 'ABC'
}
}),
],
});
Jeśli zostawiłbym to w tej formie, musiałbym ponownie utworzyć klienta z nowymi nagłówkami przy każdej zmianie nagłówków. Na szczęście tę prostą formę można ulepszyć i możemy napisać w ten sposób:
const headers: Map<string, string> = new Map<string, string>();
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:2022',
headers: () => Object.fromEntries(headers)
}),
],
});
i decydować o kształcie nagłówków dynamicznie w czasie wykonywania, np. ustawiając Authorization
przez
headers.set('Authorization', 'ABC');
Czas na czas rzeczywisty z subskrypcjami tRPC
W serwerze
instalujemy ws
.
npm i ws
npm i -D @types/ws
Do router
możemy dodać nową subskrypcję, która będzie nam dawała czas co sekundę.
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);
}
})
})
Teraz musimy otworzyć serwer websocket
, więc dodajmy go za pomocą kodu:
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();
});
Sprawdziłem w insomnia
, że mogę się połączyć.
W ładunku użyłem obiektu o kształcie opisanym w specyfikacji jsonrpc
{
id: number | string;
jsonrpc?: '2.0';
method: 'subscription';
params: {
path: string;
input?: unknown; // <-- pass input of procedure, serialized by transformer
};
}
Więc teraz połączmy naszego klienta w typescripcie.
Podążając za oficjalną dokumentacją, zobaczysz błąd.
ReferenceError: WebSocket is not defined
ponieważ createWSClient
zakłada, że działa w przeglądarce, ale w tym przykładzie używamy klienta node.
Aby to naprawić, musimy zainstalować ws
i przypisać go do zakresu global
, ale jeśli twój klient działa w przeglądarce, możesz pominąć ten krok.
npm i ws
npm i -D @types/ws
Teraz możesz stworzyć wsClient
const WebSocket = require('ws');
const wsClient = createWSClient({
url: `ws://localhost:3001`,
WebSocket: WebSocket,
});
użyj tego opakowując w link
const client = createTRPCProxyClient<AppRouter>({
links: [
wsLink({
client: wsClient
}),
],
});
a na koniec subskrybuj, aby zobaczyć serię dat
client.time.subscribe(undefined, {
onData: (time) => {
console.log(time)
}
})
Niestety, musisz usunąć naszą tajną mutację.
await client.secret.mutate();
aby to działało.
Brak dokumentacji - uwierzytelnianie websocket w tRPC
Teraz borykamy się z problemem zapewnienia uwierzytelnienia dla websocketów, ale prawdopodobnie wiesz, że czyste websockety nie obsługują nagłówków http. Możesz je przekazać w żądaniu http przy ustalaniu połączenia, które zaktualizuje protokół do websocketu. Szczegóły opisane są w RFC 6455.
W bardziej dojrzałych projektach, takich jak apollo server, można zauważyć, że żądanie aktualizacji jest używane do przekazywania informacji o uwierzytelnieniu, ale niestety teraz tRPC tego nie obsługuje.
Tak czy inaczej, możesz podzielić swojego klienta na części http i 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)
}),
}),
],
});
Prawdopodobnie większość operacji to operacje http, więc możesz użyć mechanizmu autoryzacji opisanego wcześniej do zapytań i mutacji. W przypadku websocketu możesz użyć ładunku, aby teraz przekazać token lub zastosować sztuczkę, którą opisuję poniżej.
Aby dać ci więcej kontekstu, istnieje otwarty problem:
feat: Authentication by Websocket · Issue #3955 · trpc/trpc
Interesujący, ale wprowadzający w błąd temat na stackoverflow
HTTP headers in Websockets client API
Odpowiedź z największą liczbą głosów jest błędna, ponieważ nie uwzględnia handshake. A ws
implementuje to jako trzeci argument, ale nie możesz tego znaleźć w oficjalnym README.md
W kodzie trpc
ten trzeci argument jest pomijany
trpc/wsLink.ts at main · trpc/trpc
Nie możesz także używać Sec-WebSocket-Key
, ponieważ ws
nadpisuje go losowym hashem.
ws/websocket.js at master · websockets/ws](https://github.com/websockets/ws/blob/master/lib/websocket.js#L717-L723)
i utrata trpc
traci te informacje.
Istnieją trzy podejścia do rozwiązania tego problemu.
- przekazać nagłówek autoryzacji do handshake (łatwe, ale ograniczone i niepraktyczne)
- zbudować mapę między identyfikatorami połączeń a tymi tokenami na serwerze (ma wady, ale działa)
- przekazanie tokena do dowolnej subskrypcji w ładunku (mniej eleganckie, ale bardziej skalowalne)
Scenariusz 1: Znamy token autoryzacji przed utworzeniem klienta
To jest scenariusz, który jest niezwykle łatwy do zaimplementowania, ale niepraktyczny. Prezentuję go tylko dlatego, że nie wymaga zmian na backendzie i będzie naszym dowodem koncepcji, który wykorzystamy do poprawy w następnym kroku.
Zbudujmy twój Proxy, które doda nagłówki w każdym przypadku.
const WebSocket = require('ws');
const WebSocketProxy = new Proxy(WebSocket, {
construct(target, args) {
return new target(args[0], undefined, {
headers: Object.fromEntries(headers)
});
}
})
Ten obiekt będzie używał nagłówków zdefiniowanych wcześniej jako mapa w części dotyczącej autoryzacji.
const headers: Map<string, string> = new Map<string, string>();
args[0]
będzie adresem URL Twojego serwera, a undefined jest dla protokołu, nie musisz się tym martwić. I tak było undefined/pominięte.
Ale musimy ustawić nagłówek przez
headers.set('Authorization', 'ABC');
przed wywołaniem createWSClient
.
Teraz możesz użyć WebSocketProxy
zamiast oryginalnej implementacji Websocket
const wsClient = createWSClient({
url: `ws://localhost:3001`,
WebSocket: WebSocketProxy,
});
Klient może mieć tylko wsLink
const client = createTRPCProxyClient<AppRouter>({
links: [
wsLink({
client: wsClient
}),
],
});
Lub być podzielonym na części http i 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)
}),
}),
],
});
Po stronie serwera nie potrzebujemy zmian, ale wprowadzimy tylko jedną prostą poprawę. Zwrócimy stan auth
z subskrypcji 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);
}
})
})
Teraz nasza funkcja klienta main
będzie następująca
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}`)
}
})
}
powinniśmy zobaczyć, że wszystkie żądania działają poprawnie i mamy dostęp do tokena w kontekście websocketu również.
Ale w rzeczywistym przypadku użycia zaczynasz aplikację jako niezautoryzowany użytkownik, która będzie się uwierzytelniać za pomocą http, a następnie otworzy połączenia websocketowe, aby na nich działać.
tRPC definiuje funkcję tryReconnect
dla wsLink
, ale jej nie udostępnia. Dodatkowo lepiej by było móc się uwierzytelnić bez ponownego połączenia i specjalnego punktu końcowego websocketu dedykowanego do logowania.
Scenariusz 2: Uwierzytelniamy się za pomocą kontekstu http i przekazujemy wynik do kontekstu websocketu
Zacznijmy od projektu na wysokim poziomie.
- Skonfigurujemy nasz
sec-websocket-key
, który będziemy mogli zapisać i ponownie użyć po stronie klienta. - Skonfigurujemy mapę tych kluczy oraz ich stanów uwierzytelnienia po stronie klienta.
- Umożliwimy modyfikację tej mapy za pomocą żądań http z nagłówkami autoryzacji.
- Zobaczymy, że w stanie subskrypcji websocketu stan autoryzacji można uzyskać za pomocą
keys
.
Ustawianie nagłówka z identyfikatorem klienta
Jest otwarty pr, który pozwoli na nadpisanie sec-websocket-key
, ale teraz użyjmy innej nazwy. sec-websocket-id
wydaje się być świetne.
Więc gdy nasz klient się uruchamia (np. użytkownik wchodzi na nasz adres URL strony lub w naszym przypadku proces węzła się uruchamia), musimy wygenerować identyfikator. Skupię się na implementacji w node
, więc możemy użyć crypto
.
Nasze nowe linie po stronie klienta będą
import crypto from 'crypto';
const id = crypto.randomBytes(16).toString('hex')
headers.set('sec-websocket-id', id);
musisz je ustawić przed const wsClient = createWSClient({
.
Ustawianie mapy połączeń na serwerze
Kluczowym punktem jest to, że dzielimy nagłówki
dla linków http
i websocket
. W serwerze createContext
spodziewamy się zobaczyć ten nagłówek dla wszystkich typów żądań - zarówno standardowych żądań http, jak i żądań aktualizacji http, które otworzą websocket.
Nasz context.ts
można przepisać w ten sposób:
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>;
teraz nie sprawdzamy stanu autoryzacji w bieżącym żądaniu, ale ostatniej wartości zapisanej w Map
.
W naszym scenariuszu występują następujące zdarzenia:
- publiczne zapytanie
- ustawienie tokena
- prywatna mutacja <– tutaj ustawiamy autoryzację na true
- subskrypcja websocket <— tutaj używamy stanu z mapy
Potrzebujemy bardzo małych korekt w dwóch miejscach. W funkcji isAuthed
musimy wywołać autoryzację.
const isAuthed = t.middleware(({next, ctx}) => {
if (!ctx.auth()) {
throw new TRPCError({code: 'UNAUTHORIZED'});
}
return next({
ctx: {
auth: ctx.auth
}
});
});
a w subskrypcji musimy zmienić ctx.auth
na ctx.auth()
też
const interval = setInterval(() => emit.next({date: new Date(), auth: ctx.auth()}), 1000);
Sprawdźmy, czy to działa dla klienta
Aby osiągnąć bardziej dramatyczny efekt, możemy użyć setTimeout
, aby opóźnić uwierzytelnianie.
Nasza funkcja main
ma teraz formularz
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}`)
}
})
}
a w konsoli widzę
Scenariusz 3: Przekazywanie tokena w danych wejściowych subskrypcji
Aby całkowicie rozwiązać problem, przedstawiam trzeci przedostatni sposób - przekazywanie tokena w ładunku do subskrypcji, zamiast do żądań handshake. Możemy zmodyfikować nasze 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);
}
})
})
i po stronie klienta
client.time.subscribe({token: 'ABC'}, {
onData: (ctx) => {
console.log(`I am ${ctx.input_auth ? 'auth' : 'not auth'} at ${ctx.date}`)
}
})
Zalecane ulepszenia
Jeśli jesteś użytkownikiem gRPC i podobnie jak Funwithloops
z tego wątku na reddit:
https://www.reddit.com/r/node/comments/117fgb5/trpc_correct_way_to_authorize_websocket/
zastanów się nad uwierzytelnianiem WebSocket. Powinieneś potraktować ten post jako szkic napisany przez osobę, która nauczyła się tRPC
kilka godzin temu. W środowisku produkcyjnym musisz rozwiązać problem udostępniania stanu zapisanego w authState
między swoimi instancjami backendu. Prawdopodobnie będziesz potrzebować Redis do tego celu. Następnie powinieneś ustawić parametry TX
, aby nie przechowywać tych kluczy w nieskończoność. Zapomnieliśmy o punkcie końcowym wylogowania.
Używanie Redis do zarządzania uwierzytelnieniem zmniejsza wydajność w porównaniu do czystego jwt
, więc może lepszym rozwiązaniem byłoby dodanie uwierzytelnienia do swojego wejścia subskrypcyjnego, które z drugiej strony jest mniej czytelne i wymaga więcej szablonowego kodu.
Powinieneś być świadomy, że trpc
nie implementuje opcji lazy
dla klienta WebSocket, która jest dostępna w Apollo i uprościłaby nasz pierwszy scenariusz, który opisałem tutaj.
Ta technologia jest bardzo na czasie, ale wciąż jest w fazie rozwoju i ten artykuł może wkrótce stać się nieaktualny.
Jeśli jesteś jednym z maintainerów trpc
, możesz użyć koncepcji przedstawionych tutaj w oficjalnej dokumentacji lub zasugerować mi lepsze podejście do autoryzacji websocket w sekcji komentarzy.
Other articles
You can find interesting also.
Najmniejsza wspólna wielokrotność - teoria liczb
Rozwiązanie zadania "Archery" z działu "Teoria Liczb" serwisu "Hacker Earth". Zadanie polega na wyznaczeniu najmniejszej wspólnej wielokrotności ciągu liczb.
Daniel Gustaw
• 5 min read
Jeszcze jedna instrukcja instalacji Arch Linux (i3)
Instalacja Arch Linux za każdym razem uczy mnie czegoś nowego na temat dysków, sieci, systemów operacyjnych. Polecam Ci ją jeśli chcesz mieć system skrojony pod Twoje wymagania.
Daniel Gustaw
• 15 min read
Fetch, Promise i Template String na przykładzie Listy Zadań w JavaScript
Ten prosty projekt jest doskonałym wprowadzeniem do programowania w JavaScript. Nacisk kładzie się na elementy ES6 i frontend.
Daniel Gustaw
• 13 min read