UEA-Prodem
This commit is contained in:
+203
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: adapter-aws-lambda
|
||||
description: >
|
||||
Deploy tRPC on AWS Lambda with awsLambdaRequestHandler() from
|
||||
@trpc/server/adapters/aws-lambda for API Gateway v1 (REST, APIGatewayProxyEvent)
|
||||
and v2 (HTTP, APIGatewayProxyEventV2), and Lambda Function URLs. Enable response
|
||||
streaming with awsLambdaStreamingRequestHandler() wrapped in
|
||||
awslambda.streamifyResponse(). CreateAWSLambdaContextOptions provides event and
|
||||
context for context creation.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- www/docs/server/adapters/aws-lambda.md
|
||||
- examples/lambda-url/
|
||||
- examples/lambda-api-gateway/
|
||||
---
|
||||
|
||||
# tRPC — Adapter: AWS Lambda
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import type { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';
|
||||
import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';
|
||||
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const appRouter = t.router({
|
||||
greet: t.procedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => ({ greeting: `Hello, ${input.name}!` })),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
const createContext = ({
|
||||
event,
|
||||
context,
|
||||
}: CreateAWSLambdaContextOptions<APIGatewayProxyEventV2>) => ({
|
||||
event,
|
||||
lambdaContext: context,
|
||||
});
|
||||
|
||||
export const handler = awsLambdaRequestHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### API Gateway v1 (REST) handler
|
||||
|
||||
```ts
|
||||
import type { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';
|
||||
import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';
|
||||
import type { APIGatewayProxyEvent } from 'aws-lambda';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const createContext = ({
|
||||
event,
|
||||
context,
|
||||
}: CreateAWSLambdaContextOptions<APIGatewayProxyEvent>) => ({
|
||||
user: event.requestContext.authorizer?.claims,
|
||||
});
|
||||
|
||||
export const handler = awsLambdaRequestHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
```
|
||||
|
||||
Use `APIGatewayProxyEvent` for REST API (v1 payload format) and `APIGatewayProxyEventV2` for HTTP API (v2 payload format).
|
||||
|
||||
### Response streaming with awsLambdaStreamingRequestHandler
|
||||
|
||||
```ts
|
||||
/// <reference types="aws-lambda" />
|
||||
import type { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';
|
||||
import { awsLambdaStreamingRequestHandler } from '@trpc/server/adapters/aws-lambda';
|
||||
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const createContext = ({
|
||||
event,
|
||||
context,
|
||||
}: CreateAWSLambdaContextOptions<APIGatewayProxyEventV2>) => ({});
|
||||
|
||||
export const handler = awslambda.streamifyResponse(
|
||||
awsLambdaStreamingRequestHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Response streaming is supported for Lambda Function URLs and API Gateway REST APIs (with `responseTransferMode: STREAM`). The `awslambda` namespace is provided by the Lambda execution environment.
|
||||
|
||||
### Streaming async generator procedure
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const appRouter = t.router({
|
||||
countdown: t.procedure.query(async function* () {
|
||||
for (let i = 10; i >= 0; i--) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
yield i;
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Pair with `httpBatchStreamLink` on the client for streamed responses.
|
||||
|
||||
### Limiting batch size with maxBatchSize
|
||||
|
||||
```ts
|
||||
import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';
|
||||
import { appRouter } from './router';
|
||||
|
||||
export const handler = awsLambdaRequestHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
maxBatchSize: 10,
|
||||
});
|
||||
```
|
||||
|
||||
Requests batching more than `maxBatchSize` operations are rejected with a `400 Bad Request` error. Set `maxItems` on your client's `httpBatchLink` to the same value to avoid exceeding the limit.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Using httpBatchLink with per-procedure API Gateway resources
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
// API Gateway has a separate resource for each procedure
|
||||
// e.g., /getUser, /createUser
|
||||
// Client uses:
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
|
||||
httpBatchLink({ url: 'https://api.example.com' });
|
||||
// Batch request to /getUser,createUser → 404
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { httpBatchLink, httpLink } from '@trpc/client';
|
||||
|
||||
// Option A: Single catch-all resource (e.g., /{proxy+})
|
||||
httpBatchLink({ url: 'https://api.example.com' });
|
||||
|
||||
// Option B: Per-procedure resources with httpLink (no batching)
|
||||
httpLink({ url: 'https://api.example.com' });
|
||||
```
|
||||
|
||||
`httpBatchLink` sends multiple procedure names in the URL path (e.g., `getUser,createUser`). If API Gateway routes are per-procedure, the combined path does not match any resource and returns 404. Use a single catch-all resource or switch to `httpLink`.
|
||||
|
||||
Source: www/docs/server/adapters/aws-lambda.md
|
||||
|
||||
### HIGH Forgetting streamifyResponse wrapper for streaming
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
export const handler = awsLambdaStreamingRequestHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
export const handler = awslambda.streamifyResponse(
|
||||
awsLambdaStreamingRequestHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
`awsLambdaStreamingRequestHandler` requires wrapping with `awslambda.streamifyResponse()` to enable Lambda response streaming. Without it, Lambda treats the handler as a standard buffered response.
|
||||
|
||||
Source: www/docs/server/adapters/aws-lambda.md
|
||||
|
||||
## See Also
|
||||
|
||||
- **server-setup** -- `initTRPC.create()`, router/procedure definition, context
|
||||
- **adapter-fetch** -- alternative for edge/serverless runtimes using Fetch API
|
||||
- **links** -- `httpBatchLink` vs `httpLink` for API Gateway routing considerations
|
||||
- AWS Lambda docs: https://docs.aws.amazon.com/lambda/latest/dg/welcome.html
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
---
|
||||
name: adapter-express
|
||||
description: >
|
||||
Mount tRPC as Express middleware with createExpressMiddleware() from
|
||||
@trpc/server/adapters/express. Access Express req/res in createContext via
|
||||
CreateExpressContextOptions. Mount at a path prefix like app.use('/trpc', ...).
|
||||
Avoid global express.json() conflicting with tRPC body parsing for FormData.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- www/docs/server/adapters/express.md
|
||||
- examples/express-server/src/server.ts
|
||||
---
|
||||
|
||||
# tRPC — Adapter: Express
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
const createContext = ({
|
||||
req,
|
||||
res,
|
||||
}: trpcExpress.CreateExpressContextOptions) => {
|
||||
return { req, res };
|
||||
};
|
||||
type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
const appRouter = t.router({
|
||||
greet: t.procedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => ({ greeting: `Hello, ${input.name}!` })),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
'/trpc',
|
||||
trpcExpress.createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
|
||||
app.listen(4000, () => {
|
||||
console.log('Listening on http://localhost:4000');
|
||||
});
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Accessing Express req/res in context
|
||||
|
||||
```ts
|
||||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
|
||||
const createContext = ({
|
||||
req,
|
||||
res,
|
||||
}: trpcExpress.CreateExpressContextOptions) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
return { token, res };
|
||||
};
|
||||
|
||||
type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
```
|
||||
|
||||
`CreateExpressContextOptions` provides typed access to the Express `req` (IncomingMessage) and `res` (ServerResponse).
|
||||
|
||||
### Adding tRPC alongside existing Express routes
|
||||
|
||||
```ts
|
||||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import { createContext } from './context';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use(
|
||||
'/trpc',
|
||||
trpcExpress.createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
|
||||
app.listen(4000);
|
||||
```
|
||||
|
||||
### Limiting batch size with maxBatchSize
|
||||
|
||||
```ts
|
||||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
import express from 'express';
|
||||
import { createContext } from './context';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
'/trpc',
|
||||
trpcExpress.createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
maxBatchSize: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
app.listen(4000);
|
||||
```
|
||||
|
||||
Requests batching more than `maxBatchSize` operations are rejected with a `400 Bad Request` error. Set `maxItems` on your client's `httpBatchLink` to the same value to avoid exceeding the limit.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Global express.json() consuming tRPC request body
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const app = express();
|
||||
app.use(express.json()); // global body parser
|
||||
app.use(
|
||||
'/trpc',
|
||||
trpcExpress.createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const app = express();
|
||||
// Only apply body parser to non-tRPC routes
|
||||
app.use('/api', express.json());
|
||||
app.use(
|
||||
'/trpc',
|
||||
trpcExpress.createExpressMiddleware({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
If `express.json()` is applied globally before the tRPC middleware, it consumes and parses the request body. tRPC then receives an already-parsed body, which breaks FormData and binary content type handling.
|
||||
|
||||
Source: www/docs/server/non-json-content-types.md
|
||||
|
||||
## See Also
|
||||
|
||||
- **server-setup** -- `initTRPC.create()`, router/procedure definition, context
|
||||
- **adapter-standalone** -- simpler adapter when Express middleware ecosystem is not needed
|
||||
- **auth** -- extracting JWT from `req.headers.authorization` in context
|
||||
- Express docs: https://expressjs.com/en/guide/using-middleware.html
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: adapter-fastify
|
||||
description: >
|
||||
Mount tRPC as a Fastify plugin with fastifyTRPCPlugin from
|
||||
@trpc/server/adapters/fastify. Configure prefix, trpcOptions (router,
|
||||
createContext, onError). Enable WebSocket subscriptions with useWSS and
|
||||
@fastify/websocket. Set routerOptions.maxParamLength for batch requests.
|
||||
Requires Fastify v5+. FastifyTRPCPluginOptions for type-safe onError.
|
||||
CreateFastifyContextOptions provides req, res.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- www/docs/server/adapters/fastify.md
|
||||
- examples/fastify-server/
|
||||
---
|
||||
|
||||
# tRPC — Adapter: Fastify
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import {
|
||||
fastifyTRPCPlugin,
|
||||
FastifyTRPCPluginOptions,
|
||||
} from '@trpc/server/adapters/fastify';
|
||||
import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';
|
||||
import fastify from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
function createContext({ req, res }: CreateFastifyContextOptions) {
|
||||
return { req, res };
|
||||
}
|
||||
type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
const appRouter = t.router({
|
||||
greet: t.procedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => ({ greeting: `Hello, ${input.name}!` })),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
const server = fastify({
|
||||
routerOptions: {
|
||||
maxParamLength: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ path, error }) {
|
||||
console.error(`Error in tRPC handler on path '${path}':`, error);
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await server.listen({ port: 3000 });
|
||||
console.log('Listening on http://localhost:3000');
|
||||
} catch (err) {
|
||||
server.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### WebSocket subscriptions with @fastify/websocket
|
||||
|
||||
```ts
|
||||
import ws from '@fastify/websocket';
|
||||
import {
|
||||
fastifyTRPCPlugin,
|
||||
FastifyTRPCPluginOptions,
|
||||
} from '@trpc/server/adapters/fastify';
|
||||
import fastify from 'fastify';
|
||||
import { createContext } from './context';
|
||||
import { appRouter, type AppRouter } from './router';
|
||||
|
||||
const server = fastify({
|
||||
routerOptions: { maxParamLength: 5000 },
|
||||
});
|
||||
|
||||
// Register @fastify/websocket BEFORE the tRPC plugin
|
||||
server.register(ws);
|
||||
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
useWSS: true,
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
keepAlive: {
|
||||
enabled: true,
|
||||
pingMs: 30000,
|
||||
pongWaitMs: 5000,
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
|
||||
server.listen({ port: 3000 });
|
||||
```
|
||||
|
||||
Install: `npm install @fastify/websocket` (minimum version 3.11.0)
|
||||
|
||||
### Type-safe onError with satisfies
|
||||
|
||||
```ts
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
onError({ path, error, type, input }) {
|
||||
console.error(`[${type}] ${path}:`, error.message);
|
||||
},
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
```
|
||||
|
||||
Due to Fastify plugin type inference limitations, use `satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions']` to get correct types on `onError` and other callbacks.
|
||||
|
||||
### Limiting batch size with maxBatchSize
|
||||
|
||||
```ts
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext,
|
||||
maxBatchSize: 10,
|
||||
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
||||
});
|
||||
```
|
||||
|
||||
Requests batching more than `maxBatchSize` operations are rejected with a `400 Bad Request` error. Set `maxItems` on your client's `httpBatchLink` to the same value to avoid exceeding the limit.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Registering @fastify/websocket after tRPC plugin
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
useWSS: true,
|
||||
trpcOptions: { router: appRouter, createContext },
|
||||
});
|
||||
server.register(ws); // too late!
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
server.register(ws); // register FIRST
|
||||
server.register(fastifyTRPCPlugin, {
|
||||
useWSS: true,
|
||||
trpcOptions: { router: appRouter, createContext },
|
||||
});
|
||||
```
|
||||
|
||||
The WebSocket plugin must be registered before the tRPC plugin. Reverse order causes WebSocket routes to not be recognized.
|
||||
|
||||
Source: www/docs/server/adapters/fastify.md
|
||||
|
||||
### HIGH Missing maxParamLength for batch requests
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const server = fastify();
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const server = fastify({
|
||||
routerOptions: { maxParamLength: 5000 },
|
||||
});
|
||||
```
|
||||
|
||||
Fastify defaults to `maxParamLength: 100`. Batch requests from `httpBatchLink` encode multiple procedure names in the URL path parameter, which easily exceeds 100 characters and returns a 404.
|
||||
|
||||
Source: www/docs/server/adapters/fastify.md
|
||||
|
||||
### CRITICAL Using Fastify v4 with tRPC v11
|
||||
|
||||
Wrong:
|
||||
|
||||
```json
|
||||
{ "dependencies": { "fastify": "^4.0.0" } }
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```json
|
||||
{ "dependencies": { "fastify": "^5.0.0" } }
|
||||
```
|
||||
|
||||
tRPC v11 requires Fastify v5+. Fastify v4 may return empty responses without errors due to incompatible response handling.
|
||||
|
||||
Source: www/docs/server/adapters/fastify.md
|
||||
|
||||
## See Also
|
||||
|
||||
- **server-setup** -- `initTRPC.create()`, router/procedure definition, context
|
||||
- **subscriptions** -- async generator subscriptions, `tracked()`, `keepAlive`
|
||||
- **adapter-standalone** -- simpler adapter when Fastify features are not needed
|
||||
- Fastify docs: https://fastify.dev/docs/latest/
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
---
|
||||
name: adapter-fetch
|
||||
description: >
|
||||
Deploy tRPC on WinterCG-compliant edge runtimes with fetchRequestHandler() from
|
||||
@trpc/server/adapters/fetch. Supports Cloudflare Workers, Deno Deploy, Vercel
|
||||
Edge Runtime, Astro, Remix, SolidStart. FetchCreateContextFnOptions provides
|
||||
req (Request) and resHeaders (Headers) for context creation. The endpoint option
|
||||
must match the URL path prefix where the handler is mounted.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- www/docs/server/adapters/fetch.mdx
|
||||
- examples/cloudflare-workers/
|
||||
- examples/deno-deploy/
|
||||
---
|
||||
|
||||
# tRPC — Adapter: Fetch / Edge Runtimes
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// Cloudflare Worker example
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
function createContext({ req, resHeaders }: FetchCreateContextFnOptions) {
|
||||
const user = req.headers.get('authorization');
|
||||
return { user, resHeaders };
|
||||
}
|
||||
type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
const appRouter = t.router({
|
||||
greet: t.procedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => ({ greeting: `Hello, ${input.name}!` })),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/trpc',
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Cloudflare Workers
|
||||
|
||||
```ts
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { createContext } from './context';
|
||||
import { appRouter } from './router';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/trpc',
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Astro SSR
|
||||
|
||||
```ts
|
||||
// src/pages/trpc/[trpc].ts
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createContext } from '../../server/context';
|
||||
import { appRouter } from '../../server/router';
|
||||
|
||||
export const ALL: APIRoute = (opts) => {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/trpc',
|
||||
req: opts.request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Remix
|
||||
|
||||
```ts
|
||||
// app/routes/trpc.$trpc.ts
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { createContext } from '~/server/context';
|
||||
import { appRouter } from '~/server/router';
|
||||
|
||||
function handleRequest(args: LoaderFunctionArgs | ActionFunctionArgs) {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/trpc',
|
||||
req: args.request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
}
|
||||
|
||||
export const loader = async (args: LoaderFunctionArgs) => handleRequest(args);
|
||||
export const action = async (args: ActionFunctionArgs) => handleRequest(args);
|
||||
```
|
||||
|
||||
### Deno Deploy
|
||||
|
||||
```ts
|
||||
import { fetchRequestHandler } from 'npm:@trpc/server/adapters/fetch';
|
||||
import { createContext } from './context.ts';
|
||||
import { appRouter } from './router.ts';
|
||||
|
||||
Deno.serve((request) => {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/trpc',
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Limiting batch size with maxBatchSize
|
||||
|
||||
```ts
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { createContext } from './context';
|
||||
import { appRouter } from './router';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
return fetchRequestHandler({
|
||||
endpoint: '/trpc',
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
maxBatchSize: 10,
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Requests batching more than `maxBatchSize` operations are rejected with a `400 Bad Request` error. Set `maxItems` on your client's `httpBatchLink` to the same value to avoid exceeding the limit.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Mismatched endpoint path in fetchRequestHandler
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
// Handler mounted at /api/trpc/[trpc] but endpoint says /trpc
|
||||
fetchRequestHandler({
|
||||
endpoint: '/trpc',
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
// endpoint must match the actual URL path prefix
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req: request,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
```
|
||||
|
||||
The `endpoint` option tells tRPC where to strip the URL prefix to extract the procedure name. If it does not match the actual mount path, all procedures return 404 because the path parsing extracts the wrong procedure name.
|
||||
|
||||
Source: www/docs/server/adapters/fetch.mdx
|
||||
|
||||
## See Also
|
||||
|
||||
- **server-setup** -- `initTRPC.create()`, router/procedure definition, context
|
||||
- **adapter-standalone** -- alternative for Node.js HTTP server
|
||||
- **adapter-express** -- alternative when Express middleware ecosystem is needed
|
||||
- **adapter-aws-lambda** -- alternative for AWS Lambda deployments
|
||||
- Cloudflare Workers docs: https://developers.cloudflare.com/workers/
|
||||
- Deno Deploy docs: https://deno.com/deploy/docs
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: adapter-standalone
|
||||
description: >
|
||||
Mount tRPC on Node.js built-in HTTP server with createHTTPServer() from
|
||||
@trpc/server/adapters/standalone, createHTTPHandler() for custom http.createServer,
|
||||
createHTTP2Handler() for HTTP/2 with TLS. Configure basePath to slice URL prefix,
|
||||
CORS via the cors npm package passed as middleware option. CreateHTTPContextOptions
|
||||
provides req and res for context creation.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- www/docs/server/adapters/standalone.md
|
||||
- examples/standalone-server/src/server.ts
|
||||
---
|
||||
|
||||
# tRPC — Adapter: Standalone
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const appRouter = t.router({
|
||||
greet: t.procedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => ({ greeting: `Hello, ${input.name}!` })),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
}).listen(3000);
|
||||
|
||||
console.log('Listening on http://localhost:3000');
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### CORS with the cors package
|
||||
|
||||
```ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import cors from 'cors';
|
||||
import { createContext } from './context';
|
||||
import { appRouter } from './router';
|
||||
|
||||
createHTTPServer({
|
||||
middleware: cors({ origin: 'http://localhost:5173' }),
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
Install CORS support: `npm install cors @types/cors`
|
||||
|
||||
### Custom HTTP server with createHTTPHandler
|
||||
|
||||
```ts
|
||||
import { createServer } from 'http';
|
||||
import { createHTTPHandler } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const handler = createHTTPHandler({
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
createServer((req, res) => {
|
||||
if (req.url?.startsWith('/health')) {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
return;
|
||||
}
|
||||
handler(req, res);
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
### basePath for URL prefix stripping
|
||||
|
||||
```ts
|
||||
import { createServer } from 'http';
|
||||
import { createHTTPHandler } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const handler = createHTTPHandler({
|
||||
router: appRouter,
|
||||
basePath: '/trpc/',
|
||||
});
|
||||
|
||||
createServer((req, res) => {
|
||||
if (req.url?.startsWith('/trpc/')) {
|
||||
return handler(req, res);
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
The `basePath` option strips the prefix before routing, so `/trpc/greet` resolves to the `greet` procedure.
|
||||
|
||||
### HTTP/2 with createHTTP2Handler
|
||||
|
||||
```ts
|
||||
import http2 from 'http2';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHTTP2Handler } from '@trpc/server/adapters/standalone';
|
||||
import type { CreateHTTP2ContextOptions } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const tlsKey = readFileSync('./certs/server.key');
|
||||
const tlsCert = readFileSync('./certs/server.crt');
|
||||
|
||||
async function createContext(opts: CreateHTTP2ContextOptions) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const handler = createHTTP2Handler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
|
||||
const server = http2.createSecureServer(
|
||||
{ key: tlsKey, cert: tlsCert },
|
||||
(req, res) => {
|
||||
handler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
server.listen(3001);
|
||||
```
|
||||
|
||||
### Limiting batch size with maxBatchSize
|
||||
|
||||
```ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './router';
|
||||
|
||||
createHTTPServer({
|
||||
router: appRouter,
|
||||
maxBatchSize: 10,
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
Requests batching more than `maxBatchSize` operations are rejected with a `400 Bad Request` error. Set `maxItems` on your client's `httpBatchLink` to the same value to avoid exceeding the limit.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH No CORS configuration for cross-origin requests
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import cors from 'cors';
|
||||
|
||||
createHTTPServer({
|
||||
middleware: cors({ origin: 'http://localhost:5173' }),
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
The standalone adapter has no CORS handling by default. Cross-origin browser requests fail silently because preflight OPTIONS requests receive no CORS headers.
|
||||
|
||||
Source: www/docs/server/adapters/standalone.md
|
||||
|
||||
## See Also
|
||||
|
||||
- **server-setup** -- `initTRPC.create()`, router/procedure definition, context
|
||||
- **adapter-express** -- alternative adapter when you need Express middleware ecosystem
|
||||
- **adapter-fetch** -- alternative adapter for edge/serverless runtimes
|
||||
- **subscriptions** -- adding real-time subscriptions to a standalone server
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
---
|
||||
name: auth
|
||||
description: >
|
||||
Implement JWT/cookie authentication and authorization in tRPC using createContext
|
||||
for user extraction, t.middleware with opts.next({ ctx }) for context narrowing to
|
||||
non-null user, protectedProcedure base pattern, client-side Authorization headers
|
||||
via httpBatchLink headers(), WebSocket connectionParams, and SSE auth via cookies
|
||||
or EventSource polyfill custom headers.
|
||||
type: composition
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
- middlewares
|
||||
- client-setup
|
||||
sources:
|
||||
- www/docs/server/authorization.md
|
||||
- www/docs/client/headers.md
|
||||
- www/docs/client/links/httpSubscriptionLink.md
|
||||
- www/docs/server/websockets.md
|
||||
---
|
||||
|
||||
# tRPC — Auth
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
|
||||
|
||||
export async function createContext({ req }: CreateHTTPContextOptions) {
|
||||
async function getUserFromHeader() {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (token) {
|
||||
const user = await verifyJwt(token); // your JWT verification
|
||||
return user; // e.g. { id: string; name: string; role: string }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { user: await getUserFromHeader() };
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
export const protectedProcedure = t.procedure.use(
|
||||
async function isAuthed(opts) {
|
||||
const { ctx } = opts;
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return opts.next({
|
||||
ctx: {
|
||||
user: ctx.user, // narrows user to non-null
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const router = t.router;
|
||||
```
|
||||
|
||||
```ts
|
||||
// client/trpc.ts
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
let token = '';
|
||||
export function setToken(t: string) {
|
||||
token = t;
|
||||
}
|
||||
|
||||
export const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
headers() {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Context narrowing with auth middleware
|
||||
|
||||
```ts
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
type Context = { user: { id: string; role: string } | null };
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
const isAuthed = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return next({ ctx: { user: ctx.user } });
|
||||
});
|
||||
|
||||
const isAdmin = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user || ctx.user.role !== 'admin') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN' });
|
||||
}
|
||||
return next({ ctx: { user: ctx.user } });
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||
export const adminProcedure = t.procedure.use(isAdmin);
|
||||
```
|
||||
|
||||
### SSE subscription auth with EventSource polyfill
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpSubscriptionLink,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.type === 'subscription',
|
||||
true: httpSubscriptionLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
EventSource: EventSourcePolyfill,
|
||||
eventSourceOptions: async () => {
|
||||
return {
|
||||
headers: {
|
||||
authorization: `Bearer ${getToken()}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
false: httpBatchLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
headers() {
|
||||
return { Authorization: `Bearer ${getToken()}` };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### WebSocket auth with connectionParams
|
||||
|
||||
```ts
|
||||
// server/context.ts
|
||||
import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws';
|
||||
|
||||
export const createContext = async (opts: CreateWSSContextFnOptions) => {
|
||||
const token = opts.info.connectionParams?.token;
|
||||
const user = token ? await verifyJwt(token) : null;
|
||||
return { user };
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// client/trpc.ts
|
||||
import { createTRPCClient, createWSClient, wsLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
const wsClient = createWSClient({
|
||||
url: 'ws://localhost:3001',
|
||||
connectionParams: async () => ({
|
||||
token: getToken(),
|
||||
}),
|
||||
});
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [wsLink({ client: wsClient })],
|
||||
});
|
||||
```
|
||||
|
||||
### SSE auth with cookies (same domain)
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpSubscriptionLink,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.type === 'subscription',
|
||||
true: httpSubscriptionLink({
|
||||
url: '/api/trpc',
|
||||
eventSourceOptions() {
|
||||
return { withCredentials: true };
|
||||
},
|
||||
}),
|
||||
false: httpBatchLink({ url: '/api/trpc' }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Not narrowing user type in auth middleware
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const authMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
return next(); // user still nullable downstream
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const authMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
return next({ ctx: { user: ctx.user } }); // narrows to non-null
|
||||
});
|
||||
```
|
||||
|
||||
Without `opts.next({ ctx })`, downstream procedures still see `user` as `{ id: string } | null`, requiring redundant null checks.
|
||||
|
||||
Source: www/docs/server/authorization.md
|
||||
|
||||
### HIGH SSE auth via URL query params exposes tokens
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
httpSubscriptionLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
connectionParams: async () => ({
|
||||
token: 'my-secret-jwt',
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
httpSubscriptionLink({
|
||||
url: 'http://localhost:3000/trpc',
|
||||
EventSource: EventSourcePolyfill,
|
||||
eventSourceOptions: async () => ({
|
||||
headers: { authorization: 'Bearer my-secret-jwt' },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
`connectionParams` are serialized as URL query strings for SSE, exposing tokens in server logs and browser history. Use cookies for same-domain or custom headers via an EventSource polyfill instead.
|
||||
|
||||
Source: www/docs/client/links/httpSubscriptionLink.md
|
||||
|
||||
### MEDIUM Async headers causing stuck isFetching
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
httpBatchLink({
|
||||
url: '/api/trpc',
|
||||
async headers() {
|
||||
const token = await refreshToken(); // can race
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
let cachedToken: string | null = null;
|
||||
|
||||
async function ensureToken() {
|
||||
if (!cachedToken) cachedToken = await refreshToken();
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
httpBatchLink({
|
||||
url: '/api/trpc',
|
||||
async headers() {
|
||||
return { Authorization: `Bearer ${await ensureToken()}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
When the headers function is async (e.g., refreshing auth tokens), React Query's `isFetching` can get stuck permanently in certain race conditions.
|
||||
|
||||
Source: https://github.com/trpc/trpc/issues/7001
|
||||
|
||||
### HIGH Skipping auth or opening CORS too wide in prototypes
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import cors from 'cors';
|
||||
|
||||
createHTTPServer({
|
||||
middleware: cors(), // origin: '*' by default
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
}, // no auth
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import cors from 'cors';
|
||||
|
||||
createHTTPServer({
|
||||
middleware: cors({ origin: 'https://myapp.com' }),
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
Wildcard CORS and missing auth middleware are acceptable only during local development. Always restrict CORS origins and add auth before deploying.
|
||||
|
||||
Source: maintainer interview
|
||||
|
||||
## See Also
|
||||
|
||||
- **middlewares** -- context narrowing, `.use()`, `.concat()`, base procedure patterns
|
||||
- **subscriptions** -- SSE and WebSocket transport setup for authenticated subscriptions
|
||||
- **client-setup** -- `createTRPCClient`, link chain, `headers` option
|
||||
- **links** -- `splitLink`, `httpSubscriptionLink`, `wsLink` configuration
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
---
|
||||
name: caching
|
||||
description: >
|
||||
Set HTTP cache headers on tRPC query responses via responseMeta callback for
|
||||
CDN and browser caching. Configure Cache-Control, s-maxage,
|
||||
stale-while-revalidate. Handle caching with batching and authenticated requests.
|
||||
Avoid caching mutations, errors, and authenticated responses.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/server/caching.md'
|
||||
---
|
||||
|
||||
# tRPC -- Caching
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
|
||||
|
||||
export const createContext = async (opts: CreateHTTPContextOptions) => {
|
||||
return {
|
||||
req: opts.req,
|
||||
res: opts.res,
|
||||
user: null as { id: string } | null,
|
||||
};
|
||||
};
|
||||
|
||||
type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
|
||||
export const t = initTRPC.context<Context>().create();
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
public: router({
|
||||
slowQueryCached: publicProcedure.query(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
return { lastUpdated: new Date().toJSON() };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/index.ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
import { createContext } from './trpc';
|
||||
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
responseMeta(opts) {
|
||||
const { paths, errors, type } = opts;
|
||||
|
||||
const allPublic =
|
||||
paths && paths.every((path) => path.startsWith('public.'));
|
||||
const allOk = errors.length === 0;
|
||||
const isQuery = type === 'query';
|
||||
|
||||
if (allPublic && allOk && isQuery) {
|
||||
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
||||
return {
|
||||
headers: new Headers([
|
||||
[
|
||||
'cache-control',
|
||||
`s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
|
||||
],
|
||||
]),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
server.listen(3000);
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Path-based public route caching
|
||||
|
||||
```ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
import { createContext } from './trpc';
|
||||
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
responseMeta({ paths, errors, type }) {
|
||||
const allPublic =
|
||||
paths && paths.every((path) => path.startsWith('public.'));
|
||||
const allOk = errors.length === 0;
|
||||
const isQuery = type === 'query';
|
||||
|
||||
if (allPublic && allOk && isQuery) {
|
||||
return {
|
||||
headers: new Headers([
|
||||
['cache-control', 's-maxage=1, stale-while-revalidate=86400'],
|
||||
]),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Name public routes with a `public` prefix (e.g., `public.slowQueryCached`) so `responseMeta` can identify them by path.
|
||||
|
||||
### Skip caching for authenticated requests
|
||||
|
||||
```ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
import { createContext } from './trpc';
|
||||
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
responseMeta({ ctx, errors, type }) {
|
||||
if (ctx?.user || errors.length > 0 || type !== 'query') {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
headers: new Headers([
|
||||
['cache-control', 's-maxage=1, stale-while-revalidate=86400'],
|
||||
]),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### [CRITICAL] Caching authenticated responses
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
responseMeta() {
|
||||
return {
|
||||
headers: new Headers([['cache-control', 's-maxage=60']]),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
import { createContext } from './trpc';
|
||||
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
responseMeta({ ctx, errors, type }) {
|
||||
if (ctx?.user || errors.length > 0 || type !== 'query') {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
headers: new Headers([
|
||||
['cache-control', 's-maxage=1, stale-while-revalidate=86400'],
|
||||
]),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
With batching enabled by default, a cached response containing personal data could be served to other users; always check for auth context, errors, and request type before setting cache headers.
|
||||
|
||||
Source: www/docs/server/caching.md
|
||||
|
||||
### [HIGH] Next.js App Router overrides Cache-Control headers
|
||||
|
||||
There is no code fix for this -- Next.js App Router overrides `Cache-Control` headers set by tRPC via `responseMeta`. The documented caching approach using `responseMeta` does not work as expected in App Router. Use Next.js native caching mechanisms (`revalidate`, `unstable_cache`) instead when deploying on App Router.
|
||||
|
||||
Source: https://github.com/trpc/trpc/issues/5625
|
||||
|
||||
## See Also
|
||||
|
||||
- `server-setup` -- initTRPC, createContext configuration
|
||||
- `adapter-standalone` -- responseMeta option on createHTTPServer
|
||||
- `adapter-fetch` -- responseMeta option on fetchRequestHandler
|
||||
- `links` -- splitLink to separate public/private requests on the client
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
---
|
||||
name: error-handling
|
||||
description: >
|
||||
Throw typed errors with TRPCError and error codes (NOT_FOUND, UNAUTHORIZED,
|
||||
BAD_REQUEST, INTERNAL_SERVER_ERROR), configure errorFormatter for client-side
|
||||
Zod error display, handle errors globally with onError callback, map tRPC errors
|
||||
to HTTP status codes with getHTTPStatusCodeFromError().
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/server/error-handling.md'
|
||||
- 'trpc/trpc:www/docs/server/error-formatting.md'
|
||||
- 'trpc/trpc:packages/server/src/unstable-core-do-not-import/error/TRPCError.ts'
|
||||
---
|
||||
|
||||
# tRPC -- Error Handling
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
const t = initTRPC.create({
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
|
||||
? error.cause.flatten()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Throwing typed errors from procedures
|
||||
|
||||
```ts
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
userById: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const user = getUserFromDb(input.id);
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `User with id ${input.id} not found`,
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}),
|
||||
});
|
||||
|
||||
function getUserFromDb(id: string) {
|
||||
if (id === '1') return { id: '1', name: 'Katt' };
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Wrapping original errors with cause
|
||||
|
||||
```ts
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
riskyOperation: publicProcedure.mutation(async () => {
|
||||
try {
|
||||
return await externalService();
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'An unexpected error occurred, please try again later.',
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
async function externalService() {
|
||||
throw new Error('connection refused');
|
||||
}
|
||||
```
|
||||
|
||||
Pass the original error as `cause` to retain the stack trace for debugging.
|
||||
|
||||
### Global error handling with onError
|
||||
|
||||
```ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
onError(opts) {
|
||||
const { error, type, path, input, ctx, req } = opts;
|
||||
console.error('Error:', error);
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
// send to bug reporting service
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
server.listen(3000);
|
||||
```
|
||||
|
||||
### Extracting HTTP status from TRPCError
|
||||
|
||||
```ts
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
|
||||
|
||||
function handleError(error: unknown) {
|
||||
if (error instanceof TRPCError) {
|
||||
const httpCode = getHTTPStatusCodeFromError(error);
|
||||
console.log(httpCode); // e.g., 400, 401, 404, 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### [HIGH] Throwing plain Error instead of TRPCError
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure.query(() => {
|
||||
throw new Error('Not found');
|
||||
// client receives 500 INTERNAL_SERVER_ERROR
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure.query(() => {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
// client receives 404 NOT_FOUND
|
||||
});
|
||||
```
|
||||
|
||||
Plain Error objects are caught and wrapped as INTERNAL_SERVER_ERROR (500); use TRPCError with a specific code for proper HTTP status mapping.
|
||||
|
||||
Source: www/docs/server/error-handling.md
|
||||
|
||||
### [MEDIUM] Expecting stack traces in production
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
// No explicit isDev setting
|
||||
const t = initTRPC.create();
|
||||
// Stack traces may or may not appear depending on NODE_ENV
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create({
|
||||
isDev: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
```
|
||||
|
||||
Stack traces are included only when `isDev` is true (default: `NODE_ENV !== "production"`); set `isDev` explicitly for deterministic behavior across runtimes.
|
||||
|
||||
Source: www/docs/server/error-handling.md
|
||||
|
||||
### [HIGH] Not handling Zod errors in errorFormatter
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
// No errorFormatter -- client gets generic "Input validation failed"
|
||||
const t = initTRPC.create();
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
const t = initTRPC.create({
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
|
||||
? error.cause.flatten()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Without a custom errorFormatter, the client receives a generic message without field-level validation details from Zod.
|
||||
|
||||
Source: www/docs/server/error-formatting.md
|
||||
|
||||
## Error Code Reference
|
||||
|
||||
| Code | HTTP | Use when |
|
||||
| --------------------- | ---- | ------------------------------------ |
|
||||
| BAD_REQUEST | 400 | Invalid input |
|
||||
| UNAUTHORIZED | 401 | Missing or invalid auth credentials |
|
||||
| FORBIDDEN | 403 | Authenticated but not authorized |
|
||||
| NOT_FOUND | 404 | Resource does not exist |
|
||||
| CONFLICT | 409 | Request conflicts with current state |
|
||||
| UNPROCESSABLE_CONTENT | 422 | Valid syntax but semantic error |
|
||||
| TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
|
||||
| INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
|
||||
|
||||
## See Also
|
||||
|
||||
- `server-setup` -- initTRPC configuration including isDev
|
||||
- `validators` -- input validation that triggers BAD_REQUEST errors
|
||||
- `middlewares` -- auth middleware throwing UNAUTHORIZED
|
||||
- `server-side-calls` -- catching TRPCError in server-side callers
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
---
|
||||
name: middlewares
|
||||
description: >
|
||||
Create and compose tRPC middleware with t.procedure.use(), extend context via
|
||||
opts.next({ ctx }), build reusable middleware with .concat() and .unstable_pipe(),
|
||||
define base procedures like publicProcedure and authedProcedure. Access raw input
|
||||
with getRawInput(). Logging, timing, OTEL tracing patterns.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/server/middlewares.md'
|
||||
- 'trpc/trpc:www/docs/server/authorization.md'
|
||||
- 'trpc/trpc:packages/server/src/unstable-core-do-not-import/middleware.ts'
|
||||
- 'trpc/trpc:packages/server/src/unstable-core-do-not-import/procedureBuilder.ts'
|
||||
---
|
||||
|
||||
# tRPC -- Middlewares
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
type Context = {
|
||||
user?: { id: string; isAdmin: boolean };
|
||||
};
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
export const middleware = t.middleware;
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Auth middleware that narrows context type
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
type Context = {
|
||||
user?: { id: string; isAdmin: boolean };
|
||||
};
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
export const authedProcedure = t.procedure.use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return opts.next({
|
||||
ctx: {
|
||||
user: ctx.user,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const adminProcedure = t.procedure.use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
if (!ctx.user?.isAdmin) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return opts.next({
|
||||
ctx: {
|
||||
user: ctx.user,
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
After the middleware, `ctx.user` is non-nullable in downstream procedures.
|
||||
|
||||
### Logging and timing middleware
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const loggedProcedure = t.procedure.use(async (opts) => {
|
||||
const start = Date.now();
|
||||
|
||||
const result = await opts.next();
|
||||
|
||||
const durationMs = Date.now() - start;
|
||||
const meta = { path: opts.path, type: opts.type, durationMs };
|
||||
|
||||
result.ok
|
||||
? console.log('OK request timing:', meta)
|
||||
: console.error('Non-OK request timing', meta);
|
||||
|
||||
return result;
|
||||
});
|
||||
```
|
||||
|
||||
### Reusable middleware with .concat()
|
||||
|
||||
```ts
|
||||
// myPlugin.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
export function createMyPlugin() {
|
||||
const t = initTRPC.context<{}>().meta<{}>().create();
|
||||
|
||||
return {
|
||||
pluginProc: t.procedure.use((opts) => {
|
||||
return opts.next({
|
||||
ctx: {
|
||||
fromPlugin: 'hello from myPlugin' as const,
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { createMyPlugin } from './myPlugin';
|
||||
|
||||
const t = initTRPC.context<{}>().create();
|
||||
const plugin = createMyPlugin();
|
||||
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
export const procedureWithPlugin = publicProcedure.concat(plugin.pluginProc);
|
||||
```
|
||||
|
||||
`.concat()` merges a partial procedure (from any tRPC instance) into your procedure chain, as long as context and meta types overlap.
|
||||
|
||||
### Extending middlewares with .unstable_pipe()
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const fooMiddleware = t.middleware((opts) => {
|
||||
return opts.next({
|
||||
ctx: { foo: 'foo' as const },
|
||||
});
|
||||
});
|
||||
|
||||
const barMiddleware = fooMiddleware.unstable_pipe((opts) => {
|
||||
console.log(opts.ctx.foo);
|
||||
return opts.next({
|
||||
ctx: { bar: 'bar' as const },
|
||||
});
|
||||
});
|
||||
|
||||
const barProcedure = t.procedure.use(barMiddleware);
|
||||
```
|
||||
|
||||
Piped middlewares run in order and each receives the context from the previous middleware.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### [CRITICAL] Forgetting to call and return opts.next()
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const logMiddleware = t.middleware(async (opts) => {
|
||||
console.log('request started');
|
||||
// forgot to call opts.next()
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const logMiddleware = t.middleware(async (opts) => {
|
||||
console.log('request started');
|
||||
const result = await opts.next();
|
||||
console.log('request ended');
|
||||
return result;
|
||||
});
|
||||
```
|
||||
|
||||
Middleware must call `opts.next()` and return its result; forgetting this silently drops the request with an INTERNAL_SERVER_ERROR because no middleware marker is returned.
|
||||
|
||||
Source: packages/server/src/unstable-core-do-not-import/procedureBuilder.ts
|
||||
|
||||
### [HIGH] Extending context with wrong type in opts.next()
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const middleware = t.middleware(async (opts) => {
|
||||
return opts.next({ ctx: 'not-an-object' });
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
async function getUser() {
|
||||
return { id: '1', name: 'Katt' };
|
||||
}
|
||||
|
||||
const middleware = t.middleware(async (opts) => {
|
||||
return opts.next({ ctx: { user: await getUser() } });
|
||||
});
|
||||
```
|
||||
|
||||
Context extension in `opts.next({ ctx })` must be an object; passing non-object values or overwriting required keys breaks downstream procedures.
|
||||
|
||||
Source: www/docs/server/middlewares.md
|
||||
|
||||
## See Also
|
||||
|
||||
- `server-setup` -- initTRPC, routers, procedures, context
|
||||
- `validators` -- input/output validation with Zod
|
||||
- `error-handling` -- TRPCError codes used in auth middleware
|
||||
- `auth` -- full auth patterns combining middleware + client headers
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
---
|
||||
name: non-json-content-types
|
||||
description: >
|
||||
Handle FormData, file uploads, Blob, Uint8Array, and ReadableStream inputs in
|
||||
tRPC mutations. Use octetInputParser from @trpc/server/http for binary data.
|
||||
Route non-JSON requests with splitLink and isNonJsonSerializable() from
|
||||
@trpc/client. FormData and binary inputs only work with mutations (POST).
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
- links
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/server/non-json-content-types.md'
|
||||
- 'trpc/trpc:examples/next-formdata/'
|
||||
---
|
||||
|
||||
# tRPC -- Non-JSON Content Types
|
||||
|
||||
## Setup
|
||||
|
||||
Server:
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { octetInputParser } from '@trpc/server/http';
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
uploadForm: publicProcedure
|
||||
.input(z.instanceof(FormData))
|
||||
.mutation(({ input }) => {
|
||||
const name = input.get('name');
|
||||
return { greeting: `Hello ${name}` };
|
||||
}),
|
||||
uploadFile: publicProcedure.input(octetInputParser).mutation(({ input }) => {
|
||||
// input is a ReadableStream
|
||||
return { valid: true };
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
Client:
|
||||
|
||||
```ts
|
||||
// client/index.ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpLink,
|
||||
isNonJsonSerializable,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import type { AppRouter } from '../server/appRouter';
|
||||
|
||||
const url = 'http://localhost:3000';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => isNonJsonSerializable(op.input),
|
||||
true: httpLink({ url }),
|
||||
false: httpBatchLink({ url }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### FormData mutation
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
createPost: publicProcedure
|
||||
.input(z.instanceof(FormData))
|
||||
.mutation(({ input }) => {
|
||||
const title = input.get('title') as string;
|
||||
const body = input.get('body') as string;
|
||||
return { id: '1', title, body };
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// client usage
|
||||
const form = new FormData();
|
||||
form.append('title', 'Hello');
|
||||
form.append('body', 'World');
|
||||
|
||||
const result = await trpc.createPost.mutate(form);
|
||||
```
|
||||
|
||||
### Binary file upload with octetInputParser
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { octetInputParser } from '@trpc/server/http';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
upload: publicProcedure
|
||||
.input(octetInputParser)
|
||||
.mutation(async ({ input }) => {
|
||||
const reader = input.getReader();
|
||||
let totalBytes = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalBytes += value.byteLength;
|
||||
}
|
||||
return { totalBytes };
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// client usage
|
||||
const file = new File(['hello world'], 'test.txt', { type: 'text/plain' });
|
||||
const result = await trpc.upload.mutate(file);
|
||||
```
|
||||
|
||||
`octetInputParser` converts Blob, Uint8Array, and File inputs to a ReadableStream on the server.
|
||||
|
||||
### Client splitLink with superjson transformer
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpLink,
|
||||
isNonJsonSerializable,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import superjson from 'superjson';
|
||||
import type { AppRouter } from '../server/appRouter';
|
||||
|
||||
const url = 'http://localhost:3000';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => isNonJsonSerializable(op.input),
|
||||
true: httpLink({
|
||||
url,
|
||||
transformer: {
|
||||
serialize: (data) => data,
|
||||
deserialize: (data) => superjson.deserialize(data),
|
||||
},
|
||||
}),
|
||||
false: httpBatchLink({
|
||||
url,
|
||||
transformer: superjson,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
When using a transformer, the non-JSON httpLink needs a custom transformer that skips serialization for the request (FormData/binary cannot be transformed) but deserializes the response.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### [HIGH] Using httpBatchLink for FormData requests
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../server/appRouter';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [httpBatchLink({ url: 'http://localhost:3000' })],
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpLink,
|
||||
isNonJsonSerializable,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import type { AppRouter } from '../server/appRouter';
|
||||
|
||||
const url = 'http://localhost:3000';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => isNonJsonSerializable(op.input),
|
||||
true: httpLink({ url }),
|
||||
false: httpBatchLink({ url }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
FormData and binary inputs are not batchable; use `splitLink` with `isNonJsonSerializable()` to route them through `httpLink`.
|
||||
|
||||
Source: www/docs/server/non-json-content-types.md
|
||||
|
||||
### [HIGH] Global body parser intercepting FormData before tRPC
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
import express from 'express';
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter }));
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import * as trpcExpress from '@trpc/server/adapters/express';
|
||||
import express from 'express';
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
const app = express();
|
||||
app.use('/api', express.json());
|
||||
app.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter }));
|
||||
```
|
||||
|
||||
A global `express.json()` middleware consumes the request body before tRPC can read it; scope body parsing to non-tRPC routes only.
|
||||
|
||||
Source: www/docs/server/non-json-content-types.md
|
||||
|
||||
### [HIGH] FormData only works with mutations
|
||||
|
||||
FormData and binary inputs are only supported for mutations (POST requests). Using them with `.query()` throws an error because queries use HTTP GET which cannot carry a request body.
|
||||
|
||||
Source: www/docs/server/non-json-content-types.md
|
||||
|
||||
## See Also
|
||||
|
||||
- `server-setup` -- initTRPC, routers, procedures
|
||||
- `links` -- splitLink configuration for routing non-JSON requests
|
||||
- `validators` -- z.instanceof(FormData) for FormData validation
|
||||
- `adapter-express` -- Express-specific body parser considerations
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
---
|
||||
name: server-setup
|
||||
description: >
|
||||
Initialize tRPC with initTRPC.create(), define routers with t.router(),
|
||||
create procedures with .query()/.mutation()/.subscription(), configure context
|
||||
with createContext(), export AppRouter type, merge routers with t.mergeRouters(),
|
||||
lazy-load routers with lazy().
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires: []
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/server/overview.md'
|
||||
- 'trpc/trpc:www/docs/server/routers.md'
|
||||
- 'trpc/trpc:www/docs/server/procedures.md'
|
||||
- 'trpc/trpc:www/docs/server/context.md'
|
||||
- 'trpc/trpc:www/docs/server/merging-routers.md'
|
||||
- 'trpc/trpc:www/docs/main/quickstart.mdx'
|
||||
- 'trpc/trpc:packages/server/src/unstable-core-do-not-import/initTRPC.ts'
|
||||
- 'trpc/trpc:packages/server/src/unstable-core-do-not-import/router.ts'
|
||||
---
|
||||
|
||||
# tRPC -- Server Setup
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
type User = { id: string; name: string };
|
||||
|
||||
export const appRouter = router({
|
||||
userList: publicProcedure.query(async (): Promise<User[]> => {
|
||||
return [{ id: '1', name: 'Katt' }];
|
||||
}),
|
||||
userById: publicProcedure
|
||||
.input(z.string())
|
||||
.query(async ({ input }): Promise<User> => {
|
||||
return { id: input, name: 'Katt' };
|
||||
}),
|
||||
userCreate: publicProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.mutation(async ({ input }): Promise<User> => {
|
||||
return { id: '1', ...input };
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/index.ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
const server = createHTTPServer({ router: appRouter });
|
||||
server.listen(3000);
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Context with typed session
|
||||
|
||||
```ts
|
||||
// server/context.ts
|
||||
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
|
||||
|
||||
export async function createContext(opts: CreateHTTPContextOptions) {
|
||||
const token = opts.req.headers['authorization'];
|
||||
return { token };
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import type { Context } from './context';
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/index.ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
import { createContext } from './context';
|
||||
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
server.listen(3000);
|
||||
```
|
||||
|
||||
### Inner/outer context split for testability
|
||||
|
||||
```ts
|
||||
// server/context.ts
|
||||
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
|
||||
import { db } from './db';
|
||||
|
||||
interface CreateInnerContextOptions {
|
||||
session: { user: { email: string } } | null;
|
||||
}
|
||||
|
||||
export async function createContextInner(opts?: CreateInnerContextOptions) {
|
||||
return {
|
||||
db,
|
||||
session: opts?.session ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createContext(opts: CreateHTTPContextOptions) {
|
||||
const session = getSessionFromCookie(opts.req);
|
||||
const contextInner = await createContextInner({ session });
|
||||
return {
|
||||
...contextInner,
|
||||
req: opts.req,
|
||||
res: opts.res,
|
||||
};
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContextInner>>;
|
||||
```
|
||||
|
||||
Infer `Context` from `createContextInner` so server-side callers and tests never need HTTP request objects.
|
||||
|
||||
### Merging child routers
|
||||
|
||||
```ts
|
||||
// server/routers/user.ts
|
||||
import { publicProcedure, router } from '../trpc';
|
||||
|
||||
export const userRouter = router({
|
||||
list: publicProcedure.query(() => []),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/routers/post.ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from '../trpc';
|
||||
|
||||
export const postRouter = router({
|
||||
create: publicProcedure
|
||||
.input(z.object({ title: z.string() }))
|
||||
.mutation(({ input }) => ({ id: '1', ...input })),
|
||||
list: publicProcedure.query(() => []),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/routers/_app.ts
|
||||
import { router } from '../trpc';
|
||||
import { postRouter } from './post';
|
||||
import { userRouter } from './user';
|
||||
|
||||
export const appRouter = router({
|
||||
user: userRouter,
|
||||
post: postRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
### Lazy-loaded routers for serverless cold starts
|
||||
|
||||
```ts
|
||||
// server/routers/_app.ts
|
||||
import { lazy } from '@trpc/server';
|
||||
import { router } from '../trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
// Short-hand when the module has exactly one router exported
|
||||
greeting: lazy(() => import('./greeting.js')),
|
||||
// Use .then() to pick a named export when the module exports multiple routers
|
||||
user: lazy(() => import('./user.js').then((m) => m.userRouter)),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### [CRITICAL] Calling initTRPC.create() more than once
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
// file: userRouter.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
const t = initTRPC.create();
|
||||
export const userRouter = t.router({});
|
||||
|
||||
// file: postRouter.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
const t2 = initTRPC.create();
|
||||
export const postRouter = t2.router({});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
// file: trpc.ts (single file, created once)
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import type { Context } from './context';
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
Multiple tRPC instances cause type mismatches and runtime errors when routers from different instances are merged.
|
||||
|
||||
Source: www/docs/server/routers.md
|
||||
|
||||
### [HIGH] Using reserved words as procedure names
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
const appRouter = router({
|
||||
then: publicProcedure.query(() => 'hello'),
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
const appRouter = router({
|
||||
next: publicProcedure.query(() => 'hello'),
|
||||
});
|
||||
```
|
||||
|
||||
Router creation throws if procedure names are "then", "call", or "apply" because these conflict with JavaScript Proxy internals.
|
||||
|
||||
Source: packages/server/src/unstable-core-do-not-import/router.ts
|
||||
|
||||
### [CRITICAL] Importing AppRouter as a value import
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
// client.ts
|
||||
import { AppRouter } from '../server/router';
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
// client.ts
|
||||
import type { AppRouter } from '../server/router';
|
||||
```
|
||||
|
||||
A non-type import pulls the entire server bundle into the client; use `import type` so it is stripped at build time.
|
||||
|
||||
Source: www/docs/server/routers.md
|
||||
|
||||
### [MEDIUM] Creating context without inner/outer split
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import type { CreateExpressContextOptions } from '@trpc/server/adapters/express';
|
||||
|
||||
export function createContext({ req }: CreateExpressContextOptions) {
|
||||
return { db: prisma, user: getUserFromReq(req) };
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import type { CreateExpressContextOptions } from '@trpc/server/adapters/express';
|
||||
|
||||
export function createContextInner(opts: { user?: User }) {
|
||||
return { db: prisma, user: opts.user ?? null };
|
||||
}
|
||||
|
||||
export function createContext({ req }: CreateExpressContextOptions) {
|
||||
return createContextInner({ user: getUserFromReq(req) });
|
||||
}
|
||||
```
|
||||
|
||||
Without an inner context factory, server-side callers and tests must construct HTTP request objects to get context.
|
||||
|
||||
Source: www/docs/server/context.md
|
||||
|
||||
### [HIGH] Merging routers with different transformers
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
|
||||
const t1 = initTRPC.create({ transformer: superjson });
|
||||
const t2 = initTRPC.create();
|
||||
|
||||
const router1 = t1.router({ a: t1.procedure.query(() => 'a') });
|
||||
const router2 = t2.router({ b: t2.procedure.query(() => 'b') });
|
||||
|
||||
t1.mergeRouters(router1, router2);
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
|
||||
const t = initTRPC.create({ transformer: superjson });
|
||||
|
||||
const router1 = t.router({ a: t.procedure.query(() => 'a') });
|
||||
const router2 = t.router({ b: t.procedure.query(() => 'b') });
|
||||
|
||||
t.mergeRouters(router1, router2);
|
||||
```
|
||||
|
||||
`t.mergeRouters()` throws at runtime if the routers were created with different transformer or errorFormatter configurations.
|
||||
|
||||
Source: packages/server/src/unstable-core-do-not-import/router.ts
|
||||
|
||||
### [CRITICAL] Importing appRouter value into client code
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
// client.ts
|
||||
import { appRouter } from '../server/router';
|
||||
|
||||
type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
// client.ts
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
// server/router.ts
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
Importing the appRouter value bundles the entire server into the client, even if you only use `typeof`.
|
||||
|
||||
Source: www/docs/server/routers.md
|
||||
|
||||
## See Also
|
||||
|
||||
- `middlewares` -- add auth, logging, context extension to procedures
|
||||
- `validators` -- add input/output validation with Zod
|
||||
- `error-handling` -- throw and format typed errors
|
||||
- `server-side-calls` -- call procedures from server code
|
||||
- `adapter-standalone` -- mount on Node.js HTTP server
|
||||
- `adapter-fetch` -- mount on edge runtimes
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: server-side-calls
|
||||
description: >
|
||||
Call tRPC procedures directly from server code using t.createCallerFactory()
|
||||
and router.createCaller(context) for integration testing, internal server logic,
|
||||
and custom API endpoints. Catch TRPCError and extract HTTP status with
|
||||
getHTTPStatusCodeFromError(). Error handling via onError option.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/server/server-side-calls.md'
|
||||
---
|
||||
|
||||
# tRPC -- Server-Side Calls
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
type Context = { user?: { id: string } };
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { createCallerFactory, publicProcedure, router } from './trpc';
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const posts: Post[] = [{ id: '1', title: 'Hello world' }];
|
||||
|
||||
export const appRouter = router({
|
||||
post: router({
|
||||
add: publicProcedure
|
||||
.input(z.object({ title: z.string().min(2) }))
|
||||
.mutation(({ input }) => {
|
||||
const post: Post = { ...input, id: `${Math.random()}` };
|
||||
posts.push(post);
|
||||
return post;
|
||||
}),
|
||||
byId: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const post = posts.find((p) => p.id === input.id);
|
||||
if (!post) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
return post;
|
||||
}),
|
||||
list: publicProcedure.query(() => posts),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
export const createCaller = createCallerFactory(appRouter);
|
||||
```
|
||||
|
||||
```ts
|
||||
// usage
|
||||
import { createCaller } from './appRouter';
|
||||
|
||||
const caller = createCaller({ user: { id: '1' } });
|
||||
const postList = await caller.post.list();
|
||||
const newPost = await caller.post.add({ title: 'New post' });
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Integration test with createCallerFactory
|
||||
|
||||
```ts
|
||||
// server/appRouter.test.ts
|
||||
import type { inferProcedureInput } from '@trpc/server';
|
||||
import { createCaller } from './appRouter';
|
||||
import type { AppRouter } from './appRouter';
|
||||
import { createContextInner } from './context';
|
||||
|
||||
async function testAddAndGetPost() {
|
||||
const ctx = await createContextInner({ user: undefined });
|
||||
const caller = createCaller(ctx);
|
||||
|
||||
const input: inferProcedureInput<AppRouter['post']['add']> = {
|
||||
title: 'Test post',
|
||||
};
|
||||
|
||||
const post = await caller.post.add(input);
|
||||
const allPosts = await caller.post.list();
|
||||
|
||||
console.assert(allPosts.some((p) => p.id === post.id));
|
||||
}
|
||||
```
|
||||
|
||||
### Using router.createCaller() directly
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const appRouter = t.router({
|
||||
greeting: t.procedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => `Hello ${input.name}`),
|
||||
});
|
||||
|
||||
const caller = appRouter.createCaller({});
|
||||
const result = await caller.greeting({ name: 'tRPC' });
|
||||
```
|
||||
|
||||
### Error handling in a custom API endpoint
|
||||
|
||||
```ts
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const caller = appRouter.createCaller({});
|
||||
|
||||
try {
|
||||
const post = await caller.post.byId({ id: req.query.id as string });
|
||||
res.status(200).json({ data: { postTitle: post.title } });
|
||||
} catch (cause) {
|
||||
if (cause instanceof TRPCError) {
|
||||
const httpStatusCode = getHTTPStatusCodeFromError(cause);
|
||||
res.status(httpStatusCode).json({ error: { message: cause.message } });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: { message: 'Internal server error' } });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caller with onError callback
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const appRouter = t.router({
|
||||
greeting: t.procedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => {
|
||||
if (input.name === 'invalid') {
|
||||
throw new Error('Invalid name');
|
||||
}
|
||||
return `Hello ${input.name}`;
|
||||
}),
|
||||
});
|
||||
|
||||
const caller = appRouter.createCaller(
|
||||
{},
|
||||
{
|
||||
onError: (opts) => {
|
||||
console.error('An error occurred:', opts.error);
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### [HIGH] Using createCaller inside another procedure
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { appRouter } from './appRouter';
|
||||
import { createCallerFactory, publicProcedure } from './trpc';
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
|
||||
const proc = publicProcedure.query(async () => {
|
||||
const caller = createCaller({});
|
||||
return caller.post.list();
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import type { Context } from './context';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
async function listPosts(ctx: Context) {
|
||||
return ctx.db.post.findMany();
|
||||
}
|
||||
|
||||
const proc = publicProcedure.query(async ({ ctx }) => {
|
||||
return listPosts(ctx);
|
||||
});
|
||||
```
|
||||
|
||||
Calling createCaller from within a procedure re-creates context, re-runs all middleware, and re-validates input; extract shared logic into a plain function instead.
|
||||
|
||||
Source: www/docs/server/server-side-calls.md
|
||||
|
||||
### [MEDIUM] Not providing context to createCaller
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
const caller = appRouter.createCaller({});
|
||||
await caller.protectedRoute();
|
||||
// middleware throws UNAUTHORIZED because ctx.user is undefined
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { appRouter } from './appRouter';
|
||||
import { createContextInner } from './context';
|
||||
|
||||
const ctx = await createContextInner({ user: { id: '1' } });
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await caller.protectedRoute();
|
||||
```
|
||||
|
||||
`createCaller` requires a context object matching what procedures and middleware expect; passing an empty object when procedures require auth context causes runtime errors.
|
||||
|
||||
Source: www/docs/server/server-side-calls.md
|
||||
|
||||
## See Also
|
||||
|
||||
- `server-setup` -- initTRPC, routers, context configuration
|
||||
- `middlewares` -- auth middleware that callers must satisfy
|
||||
- `error-handling` -- TRPCError and getHTTPStatusCodeFromError
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
---
|
||||
name: service-oriented-architecture
|
||||
description: >
|
||||
Break a tRPC backend into multiple services with custom routing links that
|
||||
split on the first path segment (op.path.split('.')) to route to different
|
||||
backend service URLs. Define a faux gateway router that merges service routers
|
||||
for the AppRouter type without running them in the same process. Share
|
||||
procedure and router definitions via a server-lib package with a single
|
||||
initTRPC instance. Each service runs its own standalone/Express/Fastify server.
|
||||
type: composition
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
- client-setup
|
||||
- links
|
||||
sources:
|
||||
- examples/soa/
|
||||
---
|
||||
|
||||
# tRPC — Service-Oriented Architecture
|
||||
|
||||
## Setup
|
||||
|
||||
### Shared library (single initTRPC instance)
|
||||
|
||||
```ts
|
||||
// packages/server-lib/index.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
type Context = {
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
export const mergeRouters = t.mergeRouters;
|
||||
```
|
||||
|
||||
### Service A (own server)
|
||||
|
||||
```ts
|
||||
// services/service-a/router.ts
|
||||
import { publicProcedure, router } from '@myorg/server-lib';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const serviceARouter = router({
|
||||
greet: publicProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => ({ greeting: `Hello, ${input.name}!` })),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// services/service-a/index.ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { serviceARouter } from './router';
|
||||
|
||||
createHTTPServer({
|
||||
router: serviceARouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
}).listen(2021);
|
||||
```
|
||||
|
||||
### Service B (own server)
|
||||
|
||||
```ts
|
||||
// services/service-b/router.ts
|
||||
import { publicProcedure, router } from '@myorg/server-lib';
|
||||
|
||||
export const serviceBRouter = router({
|
||||
status: publicProcedure.query(() => ({ status: 'ok' })),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// services/service-b/index.ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { serviceBRouter } from './router';
|
||||
|
||||
createHTTPServer({
|
||||
router: serviceBRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
}).listen(2022);
|
||||
```
|
||||
|
||||
### Gateway (type-only, not a running server)
|
||||
|
||||
```ts
|
||||
// gateway/index.ts
|
||||
import { router } from '@myorg/server-lib';
|
||||
import { serviceARouter } from '../services/service-a/router';
|
||||
import { serviceBRouter } from '../services/service-b/router';
|
||||
|
||||
const appRouter = router({
|
||||
serviceA: serviceARouter,
|
||||
serviceB: serviceBRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
The gateway merges routers only for type inference. It does not run as a server process. The client uses the `AppRouter` type for full type safety.
|
||||
|
||||
### Client with custom routing link
|
||||
|
||||
```ts
|
||||
// client/client.ts
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../gateway';
|
||||
|
||||
export const client = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
(runtime) => {
|
||||
const servers = {
|
||||
serviceA: httpBatchLink({ url: 'http://localhost:2021' })(runtime),
|
||||
serviceB: httpBatchLink({ url: 'http://localhost:2022' })(runtime),
|
||||
};
|
||||
|
||||
return (ctx) => {
|
||||
const { op } = ctx;
|
||||
const pathParts = op.path.split('.');
|
||||
const serverName = pathParts.shift() as keyof typeof servers;
|
||||
const path = pathParts.join('.');
|
||||
|
||||
const link = servers[serverName];
|
||||
if (!link) {
|
||||
throw new Error(
|
||||
`Unknown service: ${String(serverName)}. Known: ${Object.keys(servers).join(', ')}`,
|
||||
);
|
||||
}
|
||||
return link({
|
||||
...ctx,
|
||||
op: { ...op, path },
|
||||
});
|
||||
};
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Usage
|
||||
const greeting = await client.serviceA.greet.query({ name: 'World' });
|
||||
const status = await client.serviceB.status.query();
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Path-based routing convention
|
||||
|
||||
```ts
|
||||
(runtime) => {
|
||||
const servers = {
|
||||
users: httpBatchLink({ url: 'http://users-service:3000' })(runtime),
|
||||
billing: httpBatchLink({ url: 'http://billing-service:3000' })(runtime),
|
||||
notifications: httpBatchLink({ url: 'http://notifications-service:3000' })(
|
||||
runtime,
|
||||
),
|
||||
};
|
||||
|
||||
return (ctx) => {
|
||||
const { op } = ctx;
|
||||
const [serverName, ...rest] = op.path.split('.');
|
||||
const link = servers[serverName as keyof typeof servers];
|
||||
|
||||
if (!link) {
|
||||
throw new Error(`Unknown service: ${serverName}`);
|
||||
}
|
||||
|
||||
return link({
|
||||
...ctx,
|
||||
op: { ...op, path: rest.join('.') },
|
||||
});
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The first segment of the procedure path (before the first `.`) maps to a service name. The remaining path is forwarded to the target service.
|
||||
|
||||
### Adding shared headers across services
|
||||
|
||||
```ts
|
||||
(runtime) => {
|
||||
const servers = {
|
||||
serviceA: httpBatchLink({
|
||||
url: 'http://localhost:2021',
|
||||
headers() {
|
||||
return { 'x-request-id': crypto.randomUUID() };
|
||||
},
|
||||
})(runtime),
|
||||
serviceB: httpBatchLink({
|
||||
url: 'http://localhost:2022',
|
||||
headers() {
|
||||
return { 'x-request-id': crypto.randomUUID() };
|
||||
},
|
||||
})(runtime),
|
||||
};
|
||||
|
||||
return (ctx) => {
|
||||
const [serverName, ...rest] = ctx.op.path.split('.');
|
||||
return servers[serverName as keyof typeof servers]({
|
||||
...ctx,
|
||||
op: { ...ctx.op, path: rest.join('.') },
|
||||
});
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### MEDIUM Path routing assumes first segment is server name
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
const serverName = op.path.split('.').shift();
|
||||
// Breaks if router structure changes or has nested namespaces
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
const [serverName, ...rest] = op.path.split('.');
|
||||
const link = servers[serverName as keyof typeof servers];
|
||||
if (!link) {
|
||||
throw new Error(`Unknown service: ${serverName}. Known: ${Object.keys(servers).join(', ')}`);
|
||||
}
|
||||
return link({ ...ctx, op: { ...op, path: rest.join('.') } });
|
||||
```
|
||||
|
||||
Custom routing links that split on the first path segment break silently if the router structure changes. Add validation and clear error messages when the server name is unrecognized. The path convention must be documented and enforced across teams.
|
||||
|
||||
Source: examples/soa/client/client.ts
|
||||
|
||||
## See Also
|
||||
|
||||
- **server-setup** -- single `initTRPC.create()` instance shared across services
|
||||
- **links** -- `httpBatchLink`, custom link authoring
|
||||
- **client-setup** -- `createTRPCClient`, type-safe client with `AppRouter`
|
||||
- **adapter-standalone** -- running individual service servers
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
---
|
||||
name: subscriptions
|
||||
description: >
|
||||
Set up real-time event streams with async generator subscriptions using
|
||||
.subscription(async function*() { yield }). SSE via httpSubscriptionLink is
|
||||
recommended over WebSocket. Use tracked(id, data) from @trpc/server for
|
||||
reconnection recovery with lastEventId. WebSocket via wsLink and
|
||||
createWSClient from @trpc/client, applyWSSHandler from @trpc/server/adapters/ws. Configure SSE ping with
|
||||
initTRPC.create({ sse: { ping: { enabled, intervalMs } } }). AbortSignal
|
||||
via opts.signal for cleanup. splitLink to route subscriptions.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
- links
|
||||
sources:
|
||||
- www/docs/server/subscriptions.md
|
||||
- www/docs/server/websockets.md
|
||||
- www/docs/client/links/httpSubscriptionLink.md
|
||||
- www/docs/client/links/wsLink.md
|
||||
- packages/server/src/unstable-core-do-not-import/stream/sse.ts
|
||||
- packages/server/src/unstable-core-do-not-import/stream/tracked.ts
|
||||
- examples/standalone-server/src/server.ts
|
||||
---
|
||||
|
||||
# tRPC — Subscriptions
|
||||
|
||||
## Setup
|
||||
|
||||
SSE is recommended for most subscription use cases. It is simpler to set up and does not require a WebSocket server.
|
||||
|
||||
### Server
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import EventEmitter, { on } from 'node:events';
|
||||
import { initTRPC, tracked } from '@trpc/server';
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create({
|
||||
sse: {
|
||||
ping: {
|
||||
enabled: true,
|
||||
intervalMs: 2000,
|
||||
},
|
||||
client: {
|
||||
reconnectAfterInactivityMs: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type Post = { id: string; title: string };
|
||||
const ee = new EventEmitter();
|
||||
|
||||
const appRouter = t.router({
|
||||
onPostAdd: t.procedure
|
||||
.input(z.object({ lastEventId: z.string().nullish() }).optional())
|
||||
.subscription(async function* (opts) {
|
||||
for await (const [data] of on(ee, 'add', { signal: opts.signal })) {
|
||||
const post = data as Post;
|
||||
yield tracked(post.id, post);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
}).listen(3000);
|
||||
```
|
||||
|
||||
### Client (SSE)
|
||||
|
||||
```ts
|
||||
// client.ts
|
||||
import {
|
||||
createTRPCClient,
|
||||
httpBatchLink,
|
||||
httpSubscriptionLink,
|
||||
splitLink,
|
||||
} from '@trpc/client';
|
||||
import type { AppRouter } from './server';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.type === 'subscription',
|
||||
true: httpSubscriptionLink({ url: 'http://localhost:3000' }),
|
||||
false: httpBatchLink({ url: 'http://localhost:3000' }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const subscription = trpc.onPostAdd.subscribe(
|
||||
{ lastEventId: null },
|
||||
{
|
||||
onData(post) {
|
||||
console.log('New post:', post);
|
||||
},
|
||||
onError(err) {
|
||||
console.error('Subscription error:', err);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// To stop:
|
||||
// subscription.unsubscribe();
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### tracked() for reconnection recovery
|
||||
|
||||
```ts
|
||||
import EventEmitter, { on } from 'node:events';
|
||||
import { initTRPC, tracked } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create();
|
||||
const ee = new EventEmitter();
|
||||
|
||||
const appRouter = t.router({
|
||||
onPostAdd: t.procedure
|
||||
.input(z.object({ lastEventId: z.string().nullish() }).optional())
|
||||
.subscription(async function* (opts) {
|
||||
const iterable = on(ee, 'add', { signal: opts.signal });
|
||||
|
||||
if (opts.input?.lastEventId) {
|
||||
// Fetch and yield events since lastEventId from your database
|
||||
// const missed = await db.post.findMany({ where: { id: { gt: opts.input.lastEventId } } });
|
||||
// for (const post of missed) { yield tracked(post.id, post); }
|
||||
}
|
||||
|
||||
for await (const [data] of iterable) {
|
||||
yield tracked(data.id, data);
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
When using `tracked(id, data)`, the client automatically sends `lastEventId` on reconnection. For SSE this is part of the EventSource spec; for WebSocket, `wsLink` handles it.
|
||||
|
||||
### Polling loop subscription
|
||||
|
||||
```ts
|
||||
import { initTRPC, tracked } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const appRouter = t.router({
|
||||
onNewItems: t.procedure
|
||||
.input(z.object({ lastEventId: z.coerce.date().nullish() }))
|
||||
.subscription(async function* (opts) {
|
||||
let cursor = opts.input?.lastEventId ?? null;
|
||||
|
||||
while (!opts.signal?.aborted) {
|
||||
const items = await db.item.findMany({
|
||||
where: cursor ? { createdAt: { gt: cursor } } : undefined,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
yield tracked(item.createdAt.toJSON(), item);
|
||||
cursor = item.createdAt;
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### WebSocket setup (when bidirectional communication is required)
|
||||
|
||||
```ts
|
||||
// server
|
||||
import { applyWSSHandler } from '@trpc/server/adapters/ws';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { appRouter } from './router';
|
||||
|
||||
const wss = new WebSocketServer({ port: 3001 });
|
||||
const handler = applyWSSHandler({
|
||||
wss,
|
||||
router: appRouter,
|
||||
createContext() {
|
||||
return {};
|
||||
},
|
||||
keepAlive: {
|
||||
enabled: true,
|
||||
pingMs: 30000,
|
||||
pongWaitMs: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
handler.broadcastReconnectNotification();
|
||||
wss.close();
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// client
|
||||
import {
|
||||
createTRPCClient,
|
||||
createWSClient,
|
||||
httpBatchLink,
|
||||
splitLink,
|
||||
wsLink,
|
||||
} from '@trpc/client';
|
||||
import type { AppRouter } from './server';
|
||||
|
||||
const wsClient = createWSClient({ url: 'ws://localhost:3001' });
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
splitLink({
|
||||
condition: (op) => op.type === 'subscription',
|
||||
true: wsLink({ client: wsClient }),
|
||||
false: httpBatchLink({ url: 'http://localhost:3000' }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup with try...finally
|
||||
|
||||
```ts
|
||||
const appRouter = t.router({
|
||||
events: t.procedure.subscription(async function* (opts) {
|
||||
const cleanup = registerListener();
|
||||
try {
|
||||
for await (const [data] of on(ee, 'event', { signal: opts.signal })) {
|
||||
yield data;
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
tRPC invokes `.return()` on the generator when the subscription stops, triggering the `finally` block.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### HIGH Using Observable instead of async generator
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { observable } from '@trpc/server/observable';
|
||||
|
||||
t.procedure.subscription(({ input }) => {
|
||||
return observable((emit) => {
|
||||
emit.next(data);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
t.procedure.subscription(async function* ({ input, signal }) {
|
||||
for await (const [data] of on(ee, 'event', { signal })) {
|
||||
yield data;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Observable subscriptions are deprecated and will be removed in v12. Use async generator syntax (`async function*`).
|
||||
|
||||
Source: packages/server/src/unstable-core-do-not-import/procedureBuilder.ts
|
||||
|
||||
### MEDIUM Empty string as tracked event ID
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
yield tracked('', data);
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
yield tracked(event.id.toString(), data);
|
||||
```
|
||||
|
||||
`tracked()` throws if the ID is an empty string because it conflicts with SSE "no id" semantics.
|
||||
|
||||
Source: packages/server/src/unstable-core-do-not-import/stream/tracked.ts
|
||||
|
||||
### HIGH Fetching history before setting up event listener
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
t.procedure.subscription(async function* (opts) {
|
||||
const history = await db.getEvents(); // events may fire here and be lost
|
||||
yield* history;
|
||||
for await (const event of listener) {
|
||||
yield event;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
t.procedure.subscription(async function* (opts) {
|
||||
const iterable = on(ee, 'event', { signal: opts.signal }); // listen first
|
||||
const history = await db.getEvents();
|
||||
for (const item of history) {
|
||||
yield tracked(item.id, item);
|
||||
}
|
||||
for await (const [event] of iterable) {
|
||||
yield tracked(event.id, event);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
If you fetch historical data before setting up the event listener, events emitted between the fetch and listener setup are lost.
|
||||
|
||||
Source: www/docs/server/subscriptions.md
|
||||
|
||||
### MEDIUM SSE ping interval >= client reconnect interval
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
initTRPC.create({
|
||||
sse: {
|
||||
ping: { enabled: true, intervalMs: 10000 },
|
||||
client: { reconnectAfterInactivityMs: 5000 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
initTRPC.create({
|
||||
sse: {
|
||||
ping: { enabled: true, intervalMs: 2000 },
|
||||
client: { reconnectAfterInactivityMs: 5000 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If the server ping interval is >= the client reconnect timeout, the client disconnects thinking the connection is dead before receiving a ping.
|
||||
|
||||
Source: packages/server/src/unstable-core-do-not-import/stream/sse.ts
|
||||
|
||||
### HIGH Sending custom headers with SSE without EventSource polyfill
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
httpSubscriptionLink({
|
||||
url: 'http://localhost:3000',
|
||||
// Native EventSource does not support custom headers
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
httpSubscriptionLink({
|
||||
url: 'http://localhost:3000',
|
||||
EventSource: EventSourcePolyfill,
|
||||
eventSourceOptions: async () => ({
|
||||
headers: { authorization: 'Bearer token' },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The native EventSource API does not support custom headers. Use an EventSource polyfill and pass it via the `EventSource` option on `httpSubscriptionLink`.
|
||||
|
||||
Source: www/docs/client/links/httpSubscriptionLink.md
|
||||
|
||||
### MEDIUM Choosing WebSocket when SSE would suffice
|
||||
|
||||
SSE (`httpSubscriptionLink`) is recommended for most subscription use cases. WebSockets add complexity (connection management, reconnection, keepalive, separate server process). Only use `wsLink` when bidirectional communication or WebSocket-specific features are required.
|
||||
|
||||
Source: maintainer interview
|
||||
|
||||
### MEDIUM WebSocket subscription stale inputs on reconnect
|
||||
|
||||
When a WebSocket reconnects, subscriptions re-send the original input parameters. There is no hook to re-evaluate inputs on reconnect, which can cause stale data. Consider using `tracked()` with `lastEventId` to mitigate this.
|
||||
|
||||
Source: https://github.com/trpc/trpc/issues/4122
|
||||
|
||||
## See Also
|
||||
|
||||
- **links** -- `splitLink`, `httpSubscriptionLink`, `wsLink`, `httpBatchLink`
|
||||
- **auth** -- authenticating subscription connections (connectionParams, cookies, EventSource polyfill headers)
|
||||
- **server-setup** -- `initTRPC.create()` SSE configuration options
|
||||
- **adapter-fastify** -- WebSocket subscriptions via `@fastify/websocket` and `useWSS`
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: trpc-router
|
||||
description: >
|
||||
Entry point for all tRPC skills. Decision tree routing by task: initTRPC.create(),
|
||||
t.router(), t.procedure, createTRPCClient, adapters, subscriptions, React Query,
|
||||
Next.js, links, middleware, validators, error handling, caching, FormData.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires: []
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/main/introduction.mdx'
|
||||
- 'trpc/trpc:www/docs/main/quickstart.mdx'
|
||||
---
|
||||
|
||||
# tRPC -- Skill Router
|
||||
|
||||
## Decision Tree
|
||||
|
||||
### What are you trying to do?
|
||||
|
||||
#### Define a tRPC backend (server)
|
||||
|
||||
- **Initialize tRPC, define routers, procedures, context, export AppRouter**
|
||||
-> Load skill: `server-setup`
|
||||
|
||||
- **Add middleware (.use), auth guards, logging, base procedures**
|
||||
-> Load skill: `middlewares`
|
||||
|
||||
- **Add input/output validation with Zod or other libraries**
|
||||
-> Load skill: `validators`
|
||||
|
||||
- **Throw typed errors, format errors for clients, global error handling**
|
||||
-> Load skill: `error-handling`
|
||||
|
||||
- **Call procedures from server code, write integration tests**
|
||||
-> Load skill: `server-side-calls`
|
||||
|
||||
- **Set Cache-Control headers on query responses (CDN, browser caching)**
|
||||
-> Load skill: `caching`
|
||||
|
||||
- **Accept FormData, File, Blob, or binary uploads in mutations**
|
||||
-> Load skill: `non-json-content-types`
|
||||
|
||||
- **Set up real-time subscriptions (SSE or WebSocket)**
|
||||
-> Load skill: `subscriptions`
|
||||
|
||||
#### Host the tRPC API (adapters)
|
||||
|
||||
- **Node.js built-in HTTP server (simplest, local dev)**
|
||||
-> Load skill: `adapter-standalone`
|
||||
|
||||
- **Express middleware**
|
||||
-> Load skill: `adapter-express`
|
||||
|
||||
- **Fastify plugin**
|
||||
-> Load skill: `adapter-fastify`
|
||||
|
||||
- **AWS Lambda (API Gateway v1/v2, Function URLs)**
|
||||
-> Load skill: `adapter-aws-lambda`
|
||||
|
||||
- **Fetch API / Edge (Cloudflare Workers, Deno, Vercel Edge, Astro, Remix)**
|
||||
-> Load skill: `adapter-fetch`
|
||||
|
||||
#### Consume the tRPC API (client)
|
||||
|
||||
- **Create a vanilla TypeScript client, configure links, headers, types**
|
||||
-> Load skill: `client-setup`
|
||||
|
||||
- **Configure link chain (batching, streaming, splitting, WebSocket, SSE)**
|
||||
-> Load skill: `links`
|
||||
|
||||
- **Use SuperJSON transformer for Date, Map, Set, BigInt**
|
||||
-> Load skill: `superjson`
|
||||
|
||||
#### Use tRPC with a framework
|
||||
|
||||
- **React with TanStack Query (useQuery, useMutation, queryOptions)**
|
||||
-> Load skill: `react-query-setup`
|
||||
|
||||
- **Next.js App Router (RSC, server components, HydrateClient)**
|
||||
-> Load skill: `nextjs-app-router`
|
||||
|
||||
- **Next.js Pages Router (withTRPC, SSR, SSG helpers)**
|
||||
-> Load skill: `nextjs-pages-router`
|
||||
|
||||
#### Advanced patterns
|
||||
|
||||
- **Generate OpenAPI spec, REST client from tRPC router**
|
||||
-> Load skill: `openapi`
|
||||
|
||||
- **Multi-service gateway, custom routing links, SOA**
|
||||
-> Load skill: `service-oriented-architecture`
|
||||
|
||||
- **Auth middleware + client headers + subscription auth**
|
||||
-> Load skill: `auth`
|
||||
|
||||
## Quick Reference: Minimal Working App
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => ({ greeting: `Hello ${input.name}` })),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/index.ts
|
||||
import { createHTTPServer } from '@trpc/server/adapters/standalone';
|
||||
import { appRouter } from './appRouter';
|
||||
|
||||
const server = createHTTPServer({ router: appRouter });
|
||||
server.listen(3000);
|
||||
```
|
||||
|
||||
```ts
|
||||
// client/index.ts
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '../server/appRouter';
|
||||
|
||||
const trpc = createTRPCClient<AppRouter>({
|
||||
links: [httpBatchLink({ url: 'http://localhost:3000' })],
|
||||
});
|
||||
|
||||
const result = await trpc.hello.query({ name: 'World' });
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `server-setup` -- full server initialization details
|
||||
- `client-setup` -- full client configuration
|
||||
- `adapter-standalone` -- simplest adapter for getting started
|
||||
- `react-query-setup` -- React integration
|
||||
- `nextjs-app-router` -- Next.js App Router integration
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
---
|
||||
name: validators
|
||||
description: >
|
||||
Configure input and output validation with .input() and .output() using Zod,
|
||||
Yup, Superstruct, ArkType, Valibot, Effect, or custom validator functions.
|
||||
Chain multiple .input() calls to merge object schemas. Standard Schema protocol
|
||||
support. Output validation returns INTERNAL_SERVER_ERROR on failure.
|
||||
type: core
|
||||
library: trpc
|
||||
library_version: '11.16.0'
|
||||
requires:
|
||||
- server-setup
|
||||
sources:
|
||||
- 'trpc/trpc:www/docs/server/validators.md'
|
||||
- 'trpc/trpc:packages/server/src/unstable-core-do-not-import/parser.ts'
|
||||
---
|
||||
|
||||
# tRPC -- Validators
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
// server/trpc.ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
```
|
||||
|
||||
```ts
|
||||
// server/appRouter.ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.output(z.object({ greeting: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return { greeting: `hello ${input.name}` };
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Input validation with Zod
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
userById: publicProcedure.input(z.string()).query(({ input }) => {
|
||||
return { id: input, name: 'Katt' };
|
||||
}),
|
||||
userCreate: publicProcedure
|
||||
.input(z.object({ name: z.string(), email: z.string().email() }))
|
||||
.mutation(({ input }) => {
|
||||
return { id: '1', ...input };
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Input chaining to merge object schemas
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const baseProcedure = t.procedure
|
||||
.input(z.object({ townName: z.string() }))
|
||||
.use((opts) => {
|
||||
console.log(`Request from: ${opts.input.townName}`);
|
||||
return opts.next();
|
||||
});
|
||||
|
||||
export const appRouter = t.router({
|
||||
hello: baseProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return { greeting: `Hello ${input.name}, from ${input.townName}` };
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Multiple `.input()` calls merge object properties; the final input type is `{ townName: string; name: string }`.
|
||||
|
||||
### Output validation
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
hello: publicProcedure
|
||||
.output(z.object({ greeting: z.string() }))
|
||||
.query(() => {
|
||||
return { greeting: 'hello world' };
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Output validation catches mismatches between your return type and the expected shape, useful for untrusted data sources.
|
||||
|
||||
### Custom validator function (no library)
|
||||
|
||||
```ts
|
||||
import { initTRPC } from '@trpc/server';
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const appRouter = t.router({
|
||||
hello: t.procedure
|
||||
.input((value): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
throw new Error('Input is not a string');
|
||||
})
|
||||
.output((value): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
throw new Error('Output is not a string');
|
||||
})
|
||||
.query(({ input }) => {
|
||||
return `hello ${input}`;
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### [MEDIUM] Chaining non-object inputs
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure.input(z.string()).input(z.number());
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.input(z.object({ age: z.number() }));
|
||||
```
|
||||
|
||||
Multiple `.input()` calls merge object properties; non-object schemas (string, number, array) cannot be merged and produce type errors.
|
||||
|
||||
Source: www/docs/server/validators.md
|
||||
|
||||
### [MEDIUM] Output validation failure returns 500
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure
|
||||
.output(z.object({ id: z.string() }))
|
||||
.query(() => ({ id: 123 }));
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure
|
||||
.output(z.object({ id: z.string() }))
|
||||
.query(() => ({ id: '123' }));
|
||||
```
|
||||
|
||||
If `.output()` validation fails, tRPC returns INTERNAL_SERVER_ERROR (500), not BAD_REQUEST, because the server produced invalid data.
|
||||
|
||||
Source: www/docs/server/validators.md
|
||||
|
||||
### [HIGH] Using cursor: z.optional() without nullable for infinite queries
|
||||
|
||||
Wrong:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure
|
||||
.input(z.object({ cursor: z.string().optional() }))
|
||||
.query(({ input }) => {
|
||||
return { items: [], nextCursor: input.cursor };
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
import { publicProcedure } from './trpc';
|
||||
|
||||
const proc = publicProcedure
|
||||
.input(z.object({ cursor: z.string().nullish() }))
|
||||
.query(({ input }) => {
|
||||
return { items: [], nextCursor: input.cursor };
|
||||
});
|
||||
```
|
||||
|
||||
React Query internally passes `cursor: undefined` during invalidation refetch; using `.optional()` without `.nullable()` can fail validation. Use `.nullish()` instead.
|
||||
|
||||
Source: https://github.com/trpc/trpc/issues/6862
|
||||
|
||||
## See Also
|
||||
|
||||
- `server-setup` -- initTRPC, routers, procedures
|
||||
- `error-handling` -- how validation errors surface as BAD_REQUEST
|
||||
- `error-handling` -- errorFormatter to expose Zod field errors
|
||||
- `middlewares` -- use input chaining with middleware base procedures
|
||||
Reference in New Issue
Block a user