52834aba18
* Lex SDK error handling improvements * Allow `WWWAuthenticate` to have multiple challenges for the same scheme * tidy * add tests * tests * review comments * tidy * tidy * tidy * tidy
616 lines
15 KiB
Markdown
616 lines
15 KiB
Markdown
# @atproto/lex-server
|
|
|
|
Request router for Atproto Lexicon protocols and schemas. See the [Changelog](./CHANGELOG.md) for version history.
|
|
|
|
```bash
|
|
npm install @atproto/lex-server
|
|
```
|
|
|
|
- Type-safe request routing based on Lexicon schemas
|
|
- Support for queries, procedures, and WebSocket subscriptions
|
|
- Built on Web standard `Request`/`Response` APIs (portable across runtimes)
|
|
- Custom authentication with credential passing
|
|
- Graceful shutdown with `AsyncDisposable` pattern
|
|
|
|
> [!IMPORTANT]
|
|
>
|
|
> This package is currently in **preview**. The API and features are subject to change before the stable release.
|
|
|
|
**What is this?**
|
|
|
|
Building AT Protocol servers requires handling XRPC requests, validating inputs against Lexicon schemas, managing authentication, and supporting real-time subscriptions. `@atproto/lex-server` automates this by:
|
|
|
|
1. Routing requests to type-safe handlers based on Lexicon schemas
|
|
2. Automatically validating request parameters and bodies
|
|
3. Providing a flexible authentication system with custom strategies
|
|
4. Supporting WebSocket subscriptions with backpressure handling
|
|
|
|
```typescript
|
|
import { LexRouter } from '@atproto/lex-server'
|
|
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
import * as app from './lexicons/app.js'
|
|
|
|
const router = new LexRouter({ upgradeWebSocket })
|
|
.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
const profile = await db.getProfile(params.actor)
|
|
return { body: profile }
|
|
})
|
|
.add(app.bsky.feed.post.create, {
|
|
auth: requireAuth,
|
|
handler: async ({ credentials, input }) => {
|
|
const result = await db.createPost(credentials.did, input.body)
|
|
return { body: result }
|
|
},
|
|
})
|
|
|
|
await serve(router, { port: 3000 })
|
|
```
|
|
|
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
|
|
|
- [Quick Start](#quick-start)
|
|
- [LexRouter](#lexrouter)
|
|
- [Creating a Router](#creating-a-router)
|
|
- [Adding Routes](#adding-routes)
|
|
- [Handler Context](#handler-context)
|
|
- [Handler Output](#handler-output)
|
|
- [Queries and Procedures](#queries-and-procedures)
|
|
- [Query Handler](#query-handler)
|
|
- [Procedure Handler](#procedure-handler)
|
|
- [Binary Payloads](#binary-payloads)
|
|
- [Subscriptions](#subscriptions)
|
|
- [Authentication](#authentication)
|
|
- [Custom Authentication](#custom-authentication)
|
|
- [WWW-Authenticate Headers](#www-authenticate-headers)
|
|
- [Error Handling](#error-handling)
|
|
- [LexServerError](#lexservererror)
|
|
- [LexServerAuthError](#lexserverautherror)
|
|
- [Error Handler Callback](#error-handler-callback)
|
|
- [Node.js Server](#nodejs-server)
|
|
- [serve()](#serve)
|
|
- [createServer()](#createserver)
|
|
- [toRequestListener()](#torequestlistener)
|
|
- [upgradeWebSocket()](#upgradewebsocket)
|
|
- [Advanced Usage](#advanced-usage)
|
|
- [Custom Response Objects](#custom-response-objects)
|
|
- [Response Headers](#response-headers)
|
|
- [Connection Info](#connection-info)
|
|
- [License](#license)
|
|
|
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
|
|
|
## Quick Start
|
|
|
|
**1. Install the package**
|
|
|
|
```bash
|
|
npm install @atproto/lex-server
|
|
```
|
|
|
|
**2. Generate Lexicon schemas**
|
|
|
|
Use `@atproto/lex` to generate TypeScript schemas from your Lexicon definitions:
|
|
|
|
```bash
|
|
lex install app.bsky.actor.getProfile
|
|
lex build
|
|
```
|
|
|
|
**3. Create a router and add handlers**
|
|
|
|
```typescript
|
|
import { LexRouter, LexServerError } from '@atproto/lex-server'
|
|
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
import * as app from './lexicons/app.js'
|
|
|
|
const router = new LexRouter({ upgradeWebSocket })
|
|
|
|
// Add a query handler
|
|
router.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
const profile = await db.getProfile(params.actor)
|
|
if (!profile) {
|
|
throw new LexServerError(404, {
|
|
error: 'NotFound',
|
|
message: 'Profile not found',
|
|
})
|
|
}
|
|
return { body: profile }
|
|
})
|
|
|
|
// Start the server
|
|
const server = await serve(router, { port: 3000 })
|
|
console.log('Server listening on port 3000')
|
|
```
|
|
|
|
## LexRouter
|
|
|
|
The `LexRouter` class is the core of `@atproto/lex-server`. It routes XRPC requests to type-safe handlers based on Lexicon schemas.
|
|
|
|
### Creating a Router
|
|
|
|
```typescript
|
|
import { LexRouter } from '@atproto/lex-server'
|
|
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
|
|
const router = new LexRouter({
|
|
// Required for WebSocket subscriptions (Node.js)
|
|
upgradeWebSocket,
|
|
|
|
// Optional: Add logging or error reporting for unexpected errors in handlers
|
|
onHandlerError: ({ error, request, method }) => {
|
|
console.error(`Error in ${method.nsid}:`, error)
|
|
},
|
|
|
|
// Optional: WebSocket backpressure settings
|
|
highWaterMark: 250_000, // bytes (default: 250KB)
|
|
lowWaterMark: 50_000, // bytes (default: 50KB)
|
|
})
|
|
```
|
|
|
|
### Adding Routes
|
|
|
|
Routes are added using the `.add()` method, which accepts a Lexicon schema and a handler:
|
|
|
|
```typescript
|
|
// Simple handler (no authentication)
|
|
router.add(schema, async ({ params, input, request }) => {
|
|
return { body: result }
|
|
})
|
|
|
|
// Handler with authentication
|
|
router.add(schema, {
|
|
auth: async ({ request, params }) => credentials,
|
|
handler: async ({ params, input, credentials, request }) => {
|
|
return { body: result }
|
|
},
|
|
})
|
|
```
|
|
|
|
The router supports method chaining:
|
|
|
|
```typescript
|
|
const router = new LexRouter()
|
|
.add(app.bsky.actor.getProfile, profileHandler)
|
|
.add(app.bsky.feed.getTimeline, timelineHandler)
|
|
.add(app.bsky.feed.post.create, postHandler)
|
|
```
|
|
|
|
### Handler Context
|
|
|
|
Handlers receive a context object with the following properties:
|
|
|
|
```typescript
|
|
type LexRouterHandlerContext<Method, Credentials> = {
|
|
credentials: Credentials // Result of auth function (undefined if no auth)
|
|
input: InferMethodInput<Method> // Parsed request body (procedures only)
|
|
params: InferMethodParams<Method> // Parsed URL query parameters
|
|
request: Request // Original Web Request object
|
|
connection?: ConnectionInfo // Network connection info
|
|
}
|
|
```
|
|
|
|
### Handler Output
|
|
|
|
Handlers can return various output formats:
|
|
|
|
```typescript
|
|
// JSON response (encoding inferred from schema)
|
|
return { body: { key: 'value' } }
|
|
|
|
// With custom encoding
|
|
return { encoding: 'text/plain', body: 'Hello, world!' }
|
|
|
|
// With response headers
|
|
return { body: data, headers: { 'x-custom': 'value' } }
|
|
|
|
// Empty response (200 OK with no body)
|
|
return {}
|
|
|
|
// Custom Response object (full control)
|
|
return new Response(body, { status: 201, headers })
|
|
|
|
// Proxy Response
|
|
return fetch('https://example.com/data')
|
|
```
|
|
|
|
## Queries and Procedures
|
|
|
|
### Query Handler
|
|
|
|
Queries handle `GET` requests and receive parameters from the URL query string:
|
|
|
|
```typescript
|
|
import * as app from './lexicons/app.js'
|
|
|
|
router.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
// params.actor is typed and validated
|
|
const profile = await db.getProfile(params.actor)
|
|
return { body: profile }
|
|
})
|
|
```
|
|
|
|
### Procedure Handler
|
|
|
|
Procedures handle `POST` requests and receive a request body:
|
|
|
|
```typescript
|
|
router.add(app.bsky.feed.post.create, async ({ input }) => {
|
|
// input.body contains the parsed and validated request body
|
|
const post = await db.createPost(input.body)
|
|
return { body: { uri: post.uri, cid: post.cid } }
|
|
})
|
|
```
|
|
|
|
### Binary Payloads
|
|
|
|
For endpoints that accept or return binary data:
|
|
|
|
```typescript
|
|
// Binary input
|
|
router.add(app.example.uploadBlob, async ({ input }) => {
|
|
// input.body is a Request object for streaming
|
|
// input.encoding contains the content-type
|
|
const blob = await input.body.arrayBuffer()
|
|
return { body: { cid: await store(blob) } }
|
|
})
|
|
|
|
// Binary output
|
|
router.add(app.example.getBlob, async ({ params }) => {
|
|
const stream = await getBlob(params.cid)
|
|
return {
|
|
encoding: 'application/octet-stream',
|
|
body: stream,
|
|
}
|
|
})
|
|
```
|
|
|
|
## Subscriptions
|
|
|
|
Subscriptions provide real-time data over WebSocket connections. Handlers are async generators that yield messages:
|
|
|
|
```typescript
|
|
import { LexRouter, LexError } from '@atproto/lex-server'
|
|
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
import { scheduler } from 'node:timers/promises'
|
|
|
|
const router = new LexRouter({
|
|
upgradeWebSocket, // Required for WebSocket support in nodejs
|
|
})
|
|
|
|
router.add(com.example.stream, async function* ({ params, request }) {
|
|
const { cursor = 0, limit = 10 } = params
|
|
const { signal } = request
|
|
|
|
for (let i = 0; i < limit; i++) {
|
|
// Yield messages to the client
|
|
yield com.example.stream.message.$build({
|
|
data: `Message ${cursor + i}`,
|
|
cursor: cursor + i,
|
|
})
|
|
|
|
// Wait between messages (respects abort signal)
|
|
await scheduler.wait(1000, { signal })
|
|
}
|
|
|
|
// Throwing a LexError closes the connection with an error frame
|
|
throw new LexError('LimitReached', `Limit of ${limit} messages reached`)
|
|
})
|
|
```
|
|
|
|
Messages are CBOR-encoded and sent as WebSocket binary frames. The router handles:
|
|
|
|
- WebSocket upgrade negotiation
|
|
- Backpressure management
|
|
- Graceful connection cleanup
|
|
- Error frame encoding
|
|
|
|
## Authentication
|
|
|
|
### Custom Authentication
|
|
|
|
Authentication is implemented through the `auth` function in handler configs:
|
|
|
|
```typescript
|
|
import { LexError, LexServerAuthError } from '@atproto/lex-server'
|
|
|
|
type Credentials = { did: string; scope: string[] }
|
|
|
|
const requireAuth = async ({
|
|
request,
|
|
}: {
|
|
request: Request
|
|
}): Promise<Credentials> => {
|
|
const header = request.headers.get('authorization')
|
|
if (!header?.startsWith('Bearer ')) {
|
|
throw new LexServerAuthError(
|
|
'AuthenticationRequired',
|
|
'Bearer token required',
|
|
{
|
|
Bearer: { realm: 'api' },
|
|
},
|
|
)
|
|
}
|
|
|
|
const token = header.slice(7)
|
|
const session = await verifyToken(token)
|
|
if (!session) {
|
|
throw new LexServerAuthError(
|
|
'InvalidToken',
|
|
'Token is invalid or expired',
|
|
{
|
|
Bearer: { realm: 'api', error: 'invalid_token' },
|
|
},
|
|
)
|
|
}
|
|
|
|
return { did: session.did, scope: session.scope }
|
|
}
|
|
|
|
// Use with handlers
|
|
router.add(app.bsky.feed.post.create, {
|
|
auth: requireAuth,
|
|
handler: async ({ credentials, input }) => {
|
|
// credentials.did is available here
|
|
const post = await db.createPost(credentials.did, input.body)
|
|
return { body: post }
|
|
},
|
|
})
|
|
```
|
|
|
|
The auth function:
|
|
|
|
1. Is called **before** parsing the request body
|
|
2. Receives `params`, `request`, and `connection` info
|
|
3. Should throw `LexServerError` (or `LexServerAuthError`) on failure
|
|
4. Returns credentials that are passed to the handler
|
|
|
|
### WWW-Authenticate Headers
|
|
|
|
Use `LexServerAuthError` to include `WWW-Authenticate` headers in error responses:
|
|
|
|
```typescript
|
|
import { LexServerAuthError } from '@atproto/lex-server'
|
|
|
|
// Simple Bearer challenge
|
|
throw new LexServerAuthError('AuthenticationRequired', 'Login required', {
|
|
Bearer: { realm: 'api' },
|
|
})
|
|
// WWW-Authenticate: Bearer realm="api"
|
|
|
|
// Multiple schemes
|
|
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
|
|
Bearer: { realm: 'api', scope: 'read write' },
|
|
Basic: { realm: 'api' },
|
|
})
|
|
// WWW-Authenticate: Bearer realm="api", scope="read write", Basic realm="api"
|
|
|
|
// Token68 format
|
|
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
|
|
Bearer: 'token68value',
|
|
})
|
|
// WWW-Authenticate: Bearer token68value
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### LexServerError
|
|
|
|
Throw `LexServerError` to return structured XRPC error responses:
|
|
|
|
```typescript
|
|
import { LexServerError } from '@atproto/lex-server'
|
|
|
|
router.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
try {
|
|
const profile = await db.getProfile(params.actor)
|
|
return { body: profile }
|
|
} catch (cause) {
|
|
throw new LexServerError(
|
|
404,
|
|
{
|
|
error: 'NotFound',
|
|
message: 'Profile not found',
|
|
},
|
|
{
|
|
'cache-control': 'max-age=600',
|
|
},
|
|
{ cause },
|
|
)
|
|
}
|
|
})
|
|
```
|
|
|
|
Error responses follow the XRPC format:
|
|
|
|
```json
|
|
{
|
|
"error": "NotFound",
|
|
"message": "Profile not found"
|
|
}
|
|
```
|
|
|
|
### LexServerAuthError
|
|
|
|
`LexServerAuthError` extends `LexServerError` with `WWW-Authenticate` header support:
|
|
|
|
```typescript
|
|
import { LexServerAuthError } from '@atproto/lex-server'
|
|
|
|
throw new LexServerAuthError('AuthenticationRequired', 'Invalid credentials', {
|
|
Bearer: { realm: 'api' },
|
|
})
|
|
```
|
|
|
|
This returns a 401 response with the `WWW-Authenticate` header.
|
|
|
|
### Error Handler Callback
|
|
|
|
Use `onHandlerError` to log or report unexpected errors:
|
|
|
|
```typescript
|
|
const router = new LexRouter({
|
|
onHandlerError: async ({ error, request, method }) => {
|
|
// Log errors (excluding expected abort signals)
|
|
console.error(`Error in ${method.nsid}:`, error)
|
|
await reportToSentry(error)
|
|
},
|
|
})
|
|
```
|
|
|
|
> [!NOTE]
|
|
>
|
|
> The callback is only invoked for unexpected errors, not for `LexError` instances or request aborts.
|
|
|
|
## Node.js Server
|
|
|
|
The `@atproto/lex-server/nodejs` subpath provides Node.js-specific utilities.
|
|
|
|
### serve()
|
|
|
|
Start a server and begin listening:
|
|
|
|
```typescript
|
|
import { serve } from '@atproto/lex-server/nodejs'
|
|
|
|
const server = await serve(router, { port: 3000 })
|
|
console.log('Server listening on port 3000')
|
|
|
|
// Graceful shutdown
|
|
await server.terminate()
|
|
```
|
|
|
|
The server supports `AsyncDisposable`:
|
|
|
|
```typescript
|
|
await using server = await serve(router, { port: 3000 })
|
|
// Server is automatically terminated when scope exits
|
|
```
|
|
|
|
Options:
|
|
|
|
```typescript
|
|
type StartServerOptions = {
|
|
port?: number
|
|
host?: string
|
|
gracefulTerminationTimeout?: number // ms to wait for connections to close
|
|
}
|
|
```
|
|
|
|
### createServer()
|
|
|
|
Create a server without starting it:
|
|
|
|
```typescript
|
|
import { createServer } from '@atproto/lex-server/nodejs'
|
|
|
|
const server = createServer(router, {
|
|
gracefulTerminationTimeout: 5000,
|
|
})
|
|
|
|
server.listen(3000, () => {
|
|
console.log('Server listening')
|
|
})
|
|
```
|
|
|
|
### toRequestListener()
|
|
|
|
Convert a handler to an Express/Connect-compatible middleware:
|
|
|
|
```typescript
|
|
import express from 'express'
|
|
import { toRequestListener } from '@atproto/lex-server/nodejs'
|
|
|
|
const app = express()
|
|
|
|
// Mount the XRPC router
|
|
app.use('/xrpc', toRequestListener(router.fetch))
|
|
|
|
app.listen(3000)
|
|
```
|
|
|
|
### upgradeWebSocket()
|
|
|
|
Required for WebSocket subscription support in Node.js:
|
|
|
|
```typescript
|
|
import { LexRouter } from '@atproto/lex-server'
|
|
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
|
|
const router = new LexRouter({ upgradeWebSocket })
|
|
```
|
|
|
|
## Advanced Usage
|
|
|
|
### Custom Response Objects
|
|
|
|
Return a `Response` object for full control over the response (disables any kind
|
|
of validation of the body):
|
|
|
|
```typescript
|
|
router.add(schema, async ({ params }) => {
|
|
if (params.redirect) {
|
|
return Response.redirect('https://example.com', 302)
|
|
}
|
|
|
|
return Response.json(
|
|
{ custom: true },
|
|
{
|
|
status: 201,
|
|
headers: {
|
|
'X-Custom-Header': 'value',
|
|
},
|
|
},
|
|
)
|
|
})
|
|
```
|
|
|
|
### Response Headers
|
|
|
|
Add headers to responses:
|
|
|
|
```typescript
|
|
router.add(schema, async ({ params }) => {
|
|
return {
|
|
body: { data: 'value' },
|
|
headers: {
|
|
'Cache-Control': 'public, max-age=3600',
|
|
'X-Request-Id': crypto.randomUUID(),
|
|
},
|
|
}
|
|
})
|
|
```
|
|
|
|
### Connection Info
|
|
|
|
Access network connection information:
|
|
|
|
```typescript
|
|
router.add(schema, async ({ connection }) => {
|
|
console.log('Remote address:', connection?.remoteAddr?.hostname)
|
|
console.log('Local address:', connection?.localAddr?.hostname)
|
|
return { body: { status: 'ok' } }
|
|
})
|
|
```
|
|
|
|
Connection info structure:
|
|
|
|
```typescript
|
|
type ConnectionInfo = {
|
|
localAddr?: {
|
|
hostname: string
|
|
port: number
|
|
transport: 'tcp' | 'udp'
|
|
}
|
|
remoteAddr?: {
|
|
hostname: string
|
|
port: number
|
|
transport: 'tcp' | 'udp'
|
|
}
|
|
}
|
|
```
|
|
|
|
## License
|
|
|
|
MIT or Apache2
|