@atproto/lex-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE.txt +7 -0
- package/README.md +598 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +39 -0
- package/dist/errors.js.map +1 -0
- package/dist/example.d.ts +2 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +36 -0
- package/dist/example.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lex-auth-error.d.ts +15 -0
- package/dist/lex-auth-error.d.ts.map +1 -0
- package/dist/lex-auth-error.js +52 -0
- package/dist/lex-auth-error.js.map +1 -0
- package/dist/lex-server.d.ts +80 -0
- package/dist/lex-server.d.ts.map +1 -0
- package/dist/lex-server.js +285 -0
- package/dist/lex-server.js.map +1 -0
- package/dist/lib/drain-websocket.d.ts +6 -0
- package/dist/lib/drain-websocket.d.ts.map +1 -0
- package/dist/lib/drain-websocket.js +16 -0
- package/dist/lib/drain-websocket.js.map +1 -0
- package/dist/lib/sleep.d.ts +2 -0
- package/dist/lib/sleep.d.ts.map +1 -0
- package/dist/lib/sleep.js +22 -0
- package/dist/lib/sleep.js.map +1 -0
- package/dist/lib/www-authenticate.d.ts +7 -0
- package/dist/lib/www-authenticate.d.ts.map +1 -0
- package/dist/lib/www-authenticate.js +22 -0
- package/dist/lib/www-authenticate.js.map +1 -0
- package/dist/nodejs.d.ts +35 -0
- package/dist/nodejs.d.ts.map +1 -0
- package/dist/nodejs.js +236 -0
- package/dist/nodejs.js.map +1 -0
- package/dist/subscripotion.d.ts +2 -0
- package/dist/subscripotion.d.ts.map +1 -0
- package/dist/subscripotion.js +36 -0
- package/dist/subscripotion.js.map +1 -0
- package/dist/test.d.mts +2 -0
- package/dist/test.d.mts.map +1 -0
- package/dist/test.mjs +52 -0
- package/dist/test.mjs.map +1 -0
- package/nodejs.js +5 -0
- package/package.json +64 -0
- package/src/errors.ts +54 -0
- package/src/index.ts +8 -0
- package/src/lex-server.test.ts +1621 -0
- package/src/lex-server.ts +551 -0
- package/src/lib/drain-websocket.ts +23 -0
- package/src/lib/sleep.ts +25 -0
- package/src/lib/www-authenticate.ts +26 -0
- package/src/nodejs.test.ts +107 -0
- package/src/nodejs.ts +367 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tests.json +9 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @atproto/lex-server
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#4443](https://github.com/bluesky-social/atproto/pull/4443) [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add new XRPC server library
|
|
8
|
+
|
|
9
|
+
- Updated dependencies [[`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c), [`9af7a2d`](https://github.com/bluesky-social/atproto/commit/9af7a2d12240e91248610ce4fe7d93387733c59c)]:
|
|
10
|
+
- @atproto/lex-schema@0.0.6
|
|
11
|
+
- @atproto/lex-data@0.0.5
|
|
12
|
+
- @atproto/lex-cbor@0.0.5
|
|
13
|
+
- @atproto/lex-json@0.0.5
|
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Dual MIT/Apache-2.0 License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2025 Bluesky Social PBC, and Contributors
|
|
4
|
+
|
|
5
|
+
Except as otherwise noted in individual files, this software is licensed under the MIT license (<http://opensource.org/licenses/MIT>), or the Apache License, Version 2.0 (<http://www.apache.org/licenses/LICENSE-2.0>).
|
|
6
|
+
|
|
7
|
+
Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
|
package/README.md
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
# @atproto/lex-server
|
|
2
|
+
|
|
3
|
+
Request router for Atproto Lexicon protocols and schemas. See the [Changelog](./CHANGELOG.md) for version history.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @atproto/lex-server
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- Type-safe request routing based on Lexicon schemas
|
|
10
|
+
- Support for queries, procedures, and WebSocket subscriptions
|
|
11
|
+
- Built on Web standard `Request`/`Response` APIs (portable across runtimes)
|
|
12
|
+
- Custom authentication with credential passing
|
|
13
|
+
- Graceful shutdown with `AsyncDisposable` pattern
|
|
14
|
+
|
|
15
|
+
> [!IMPORTANT]
|
|
16
|
+
>
|
|
17
|
+
> This package is currently in **preview**. The API and features are subject to change before the stable release.
|
|
18
|
+
|
|
19
|
+
**What is this?**
|
|
20
|
+
|
|
21
|
+
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:
|
|
22
|
+
|
|
23
|
+
1. Routing requests to type-safe handlers based on Lexicon schemas
|
|
24
|
+
2. Automatically validating request parameters and bodies
|
|
25
|
+
3. Providing a flexible authentication system with custom strategies
|
|
26
|
+
4. Supporting WebSocket subscriptions with backpressure handling
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { LexRouter } from '@atproto/lex-server'
|
|
30
|
+
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
31
|
+
import * as app from './lexicons/app.js'
|
|
32
|
+
|
|
33
|
+
const router = new LexRouter({ upgradeWebSocket })
|
|
34
|
+
.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
35
|
+
const profile = await db.getProfile(params.actor)
|
|
36
|
+
return { body: profile }
|
|
37
|
+
})
|
|
38
|
+
.add(app.bsky.feed.post.create, {
|
|
39
|
+
auth: requireAuth,
|
|
40
|
+
handler: async ({ credentials, input }) => {
|
|
41
|
+
const result = await db.createPost(credentials.did, input.body)
|
|
42
|
+
return { body: result }
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
await serve(router, { port: 3000 })
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
|
50
|
+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
|
51
|
+
|
|
52
|
+
- [Quick Start](#quick-start)
|
|
53
|
+
- [LexRouter](#lexrouter)
|
|
54
|
+
- [Creating a Router](#creating-a-router)
|
|
55
|
+
- [Adding Routes](#adding-routes)
|
|
56
|
+
- [Handler Context](#handler-context)
|
|
57
|
+
- [Handler Output](#handler-output)
|
|
58
|
+
- [Queries and Procedures](#queries-and-procedures)
|
|
59
|
+
- [Query Handler](#query-handler)
|
|
60
|
+
- [Procedure Handler](#procedure-handler)
|
|
61
|
+
- [Binary Payloads](#binary-payloads)
|
|
62
|
+
- [Subscriptions](#subscriptions)
|
|
63
|
+
- [Authentication](#authentication)
|
|
64
|
+
- [Custom Authentication](#custom-authentication)
|
|
65
|
+
- [WWW-Authenticate Headers](#www-authenticate-headers)
|
|
66
|
+
- [Error Handling](#error-handling)
|
|
67
|
+
- [LexError](#lexerror)
|
|
68
|
+
- [LexServerAuthError](#lexserverautherror)
|
|
69
|
+
- [Error Handler Callback](#error-handler-callback)
|
|
70
|
+
- [Node.js Server](#nodejs-server)
|
|
71
|
+
- [serve()](#serve)
|
|
72
|
+
- [createServer()](#createserver)
|
|
73
|
+
- [toRequestListener()](#torequestlistener)
|
|
74
|
+
- [upgradeWebSocket()](#upgradewebsocket)
|
|
75
|
+
- [Advanced Usage](#advanced-usage)
|
|
76
|
+
- [Custom Response Objects](#custom-response-objects)
|
|
77
|
+
- [Response Headers](#response-headers)
|
|
78
|
+
- [Connection Info](#connection-info)
|
|
79
|
+
- [License](#license)
|
|
80
|
+
|
|
81
|
+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
|
82
|
+
|
|
83
|
+
## Quick Start
|
|
84
|
+
|
|
85
|
+
**1. Install the package**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm install @atproto/lex-server
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**2. Generate Lexicon schemas**
|
|
92
|
+
|
|
93
|
+
Use `@atproto/lex` to generate TypeScript schemas from your Lexicon definitions:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
lex install app.bsky.actor.getProfile
|
|
97
|
+
lex build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**3. Create a router and add handlers**
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { LexRouter, LexError } from '@atproto/lex-server'
|
|
104
|
+
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
105
|
+
import * as app from './lexicons/app.js'
|
|
106
|
+
|
|
107
|
+
const router = new LexRouter({ upgradeWebSocket })
|
|
108
|
+
|
|
109
|
+
// Add a query handler
|
|
110
|
+
router.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
111
|
+
const profile = await db.getProfile(params.actor)
|
|
112
|
+
if (!profile) {
|
|
113
|
+
throw new LexError('NotFound', 'Profile not found')
|
|
114
|
+
}
|
|
115
|
+
return { body: profile }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Start the server
|
|
119
|
+
const server = await serve(router, { port: 3000 })
|
|
120
|
+
console.log('Server listening on port 3000')
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## LexRouter
|
|
124
|
+
|
|
125
|
+
The `LexRouter` class is the core of `@atproto/lex-server`. It routes XRPC requests to type-safe handlers based on Lexicon schemas.
|
|
126
|
+
|
|
127
|
+
### Creating a Router
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import { LexRouter } from '@atproto/lex-server'
|
|
131
|
+
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
132
|
+
|
|
133
|
+
const router = new LexRouter({
|
|
134
|
+
// Required for WebSocket subscriptions (Node.js)
|
|
135
|
+
upgradeWebSocket,
|
|
136
|
+
|
|
137
|
+
// Optional: Handle unexpected errors
|
|
138
|
+
onHandlerError: ({ error, request, method }) => {
|
|
139
|
+
console.error(`Error in ${method.nsid}:`, error)
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// Optional: WebSocket backpressure settings
|
|
143
|
+
highWaterMark: 250_000, // bytes (default: 250KB)
|
|
144
|
+
lowWaterMark: 50_000, // bytes (default: 50KB)
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Adding Routes
|
|
149
|
+
|
|
150
|
+
Routes are added using the `.add()` method, which accepts a Lexicon schema and a handler:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// Simple handler (no authentication)
|
|
154
|
+
router.add(schema, async ({ params, input, request }) => {
|
|
155
|
+
return { body: result }
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Handler with authentication
|
|
159
|
+
router.add(schema, {
|
|
160
|
+
auth: async ({ request, params }) => credentials,
|
|
161
|
+
handler: async ({ params, input, credentials, request }) => {
|
|
162
|
+
return { body: result }
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The router supports method chaining:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
const router = new LexRouter()
|
|
171
|
+
.add(app.bsky.actor.getProfile, profileHandler)
|
|
172
|
+
.add(app.bsky.feed.getTimeline, timelineHandler)
|
|
173
|
+
.add(app.bsky.feed.post.create, postHandler)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Handler Context
|
|
177
|
+
|
|
178
|
+
Handlers receive a context object with the following properties:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
type LexRouterHandlerContext<Method, Credentials> = {
|
|
182
|
+
credentials: Credentials // Result of auth function (undefined if no auth)
|
|
183
|
+
input: InferMethodInput<Method> // Parsed request body (procedures only)
|
|
184
|
+
params: InferMethodParams<Method> // Validated URL query parameters
|
|
185
|
+
request: Request // Original Web Request object
|
|
186
|
+
connection?: ConnectionInfo // Network connection info
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Handler Output
|
|
191
|
+
|
|
192
|
+
Handlers can return various output formats:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// JSON response (encoding inferred from schema)
|
|
196
|
+
return { body: { key: 'value' } }
|
|
197
|
+
|
|
198
|
+
// With custom encoding
|
|
199
|
+
return { encoding: 'text/plain', body: 'Hello, world!' }
|
|
200
|
+
|
|
201
|
+
// With response headers
|
|
202
|
+
return { body: data, headers: { 'x-custom': 'value' } }
|
|
203
|
+
|
|
204
|
+
// Empty response (200 OK with no body)
|
|
205
|
+
return {}
|
|
206
|
+
|
|
207
|
+
// Custom Response object (full control)
|
|
208
|
+
return new Response(body, { status: 201, headers })
|
|
209
|
+
|
|
210
|
+
// Proxy Response
|
|
211
|
+
return fetch('https://example.com/data')
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Queries and Procedures
|
|
215
|
+
|
|
216
|
+
### Query Handler
|
|
217
|
+
|
|
218
|
+
Queries handle `GET` requests and receive parameters from the URL query string:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import * as app from './lexicons/app.js'
|
|
222
|
+
|
|
223
|
+
router.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
224
|
+
// params.actor is typed and validated
|
|
225
|
+
const profile = await db.getProfile(params.actor)
|
|
226
|
+
return { body: profile }
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Procedure Handler
|
|
231
|
+
|
|
232
|
+
Procedures handle `POST` requests and receive a request body:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
router.add(app.bsky.feed.post.create, async ({ input }) => {
|
|
236
|
+
// input.body contains the parsed and validated request body
|
|
237
|
+
const post = await db.createPost(input.body)
|
|
238
|
+
return { body: { uri: post.uri, cid: post.cid } }
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Binary Payloads
|
|
243
|
+
|
|
244
|
+
For endpoints that accept or return binary data:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
// Binary input
|
|
248
|
+
router.add(app.example.uploadBlob, async ({ input }) => {
|
|
249
|
+
// input.body is a Request object for streaming
|
|
250
|
+
// input.encoding contains the content-type
|
|
251
|
+
const blob = await input.body.arrayBuffer()
|
|
252
|
+
return { body: { cid: await store(blob) } }
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// Binary output
|
|
256
|
+
router.add(app.example.getBlob, async ({ params }) => {
|
|
257
|
+
const stream = await getBlob(params.cid)
|
|
258
|
+
return {
|
|
259
|
+
encoding: 'application/octet-stream',
|
|
260
|
+
body: stream,
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Subscriptions
|
|
266
|
+
|
|
267
|
+
Subscriptions provide real-time data over WebSocket connections. Handlers are async generators that yield messages:
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
import { LexRouter, LexError } from '@atproto/lex-server'
|
|
271
|
+
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
272
|
+
import { scheduler } from 'node:timers/promises'
|
|
273
|
+
|
|
274
|
+
const router = new LexRouter({
|
|
275
|
+
upgradeWebSocket, // Required for WebSocket support in nodejs
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
router.add(com.example.stream, async function* ({ params, request }) {
|
|
279
|
+
const { cursor = 0, limit = 10 } = params
|
|
280
|
+
const { signal } = request
|
|
281
|
+
|
|
282
|
+
for (let i = 0; i < limit; i++) {
|
|
283
|
+
// Yield messages to the client
|
|
284
|
+
yield com.example.stream.message.$build({
|
|
285
|
+
data: `Message ${cursor + i}`,
|
|
286
|
+
cursor: cursor + i,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// Wait between messages (respects abort signal)
|
|
290
|
+
await scheduler.wait(1000, { signal })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Throwing a LexError closes the connection with an error frame
|
|
294
|
+
throw new LexError('LimitReached', `Limit of ${limit} messages reached`)
|
|
295
|
+
})
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Messages are CBOR-encoded and sent as WebSocket binary frames. The router handles:
|
|
299
|
+
|
|
300
|
+
- WebSocket upgrade negotiation
|
|
301
|
+
- Backpressure management
|
|
302
|
+
- Graceful connection cleanup
|
|
303
|
+
- Error frame encoding
|
|
304
|
+
|
|
305
|
+
## Authentication
|
|
306
|
+
|
|
307
|
+
### Custom Authentication
|
|
308
|
+
|
|
309
|
+
Authentication is implemented through the `auth` function in handler configs:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
import { LexError, LexServerAuthError } from '@atproto/lex-server'
|
|
313
|
+
|
|
314
|
+
type Credentials = { did: string; scope: string[] }
|
|
315
|
+
|
|
316
|
+
const requireAuth = async ({
|
|
317
|
+
request,
|
|
318
|
+
}: {
|
|
319
|
+
request: Request
|
|
320
|
+
}): Promise<Credentials> => {
|
|
321
|
+
const header = request.headers.get('authorization')
|
|
322
|
+
if (!header?.startsWith('Bearer ')) {
|
|
323
|
+
throw new LexServerAuthError(
|
|
324
|
+
'AuthenticationRequired',
|
|
325
|
+
'Bearer token required',
|
|
326
|
+
{
|
|
327
|
+
Bearer: { realm: 'api' },
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const token = header.slice(7)
|
|
333
|
+
const session = await verifyToken(token)
|
|
334
|
+
if (!session) {
|
|
335
|
+
throw new LexServerAuthError(
|
|
336
|
+
'InvalidToken',
|
|
337
|
+
'Token is invalid or expired',
|
|
338
|
+
{
|
|
339
|
+
Bearer: { realm: 'api', error: 'invalid_token' },
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { did: session.did, scope: session.scope }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Use with handlers
|
|
348
|
+
router.add(app.bsky.feed.post.create, {
|
|
349
|
+
auth: requireAuth,
|
|
350
|
+
handler: async ({ credentials, input }) => {
|
|
351
|
+
// credentials.did is available here
|
|
352
|
+
const post = await db.createPost(credentials.did, input.body)
|
|
353
|
+
return { body: post }
|
|
354
|
+
},
|
|
355
|
+
})
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The auth function:
|
|
359
|
+
|
|
360
|
+
1. Is called **before** parsing the request body
|
|
361
|
+
2. Receives `params`, `request`, and `connection` info
|
|
362
|
+
3. Should throw `LexError` or `LexServerAuthError` on failure
|
|
363
|
+
4. Returns credentials that are passed to the handler
|
|
364
|
+
|
|
365
|
+
### WWW-Authenticate Headers
|
|
366
|
+
|
|
367
|
+
Use `LexServerAuthError` to include `WWW-Authenticate` headers in error responses:
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
import { LexServerAuthError } from '@atproto/lex-server'
|
|
371
|
+
|
|
372
|
+
// Simple Bearer challenge
|
|
373
|
+
throw new LexServerAuthError('AuthenticationRequired', 'Login required', {
|
|
374
|
+
Bearer: { realm: 'api' },
|
|
375
|
+
})
|
|
376
|
+
// WWW-Authenticate: Bearer realm="api"
|
|
377
|
+
|
|
378
|
+
// Multiple schemes
|
|
379
|
+
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
|
|
380
|
+
Bearer: { realm: 'api', scope: 'read write' },
|
|
381
|
+
Basic: { realm: 'api' },
|
|
382
|
+
})
|
|
383
|
+
// WWW-Authenticate: Bearer realm="api", scope="read write", Basic realm="api"
|
|
384
|
+
|
|
385
|
+
// Token68 format
|
|
386
|
+
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
|
|
387
|
+
Bearer: 'token68value',
|
|
388
|
+
})
|
|
389
|
+
// WWW-Authenticate: Bearer token68value
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
## Error Handling
|
|
393
|
+
|
|
394
|
+
### LexError
|
|
395
|
+
|
|
396
|
+
Throw `LexError` to return structured XRPC error responses:
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
import { LexError } from '@atproto/lex-server'
|
|
400
|
+
|
|
401
|
+
router.add(app.bsky.actor.getProfile, async ({ params }) => {
|
|
402
|
+
const profile = await db.getProfile(params.actor)
|
|
403
|
+
if (!profile) {
|
|
404
|
+
throw new LexError('NotFound', 'Profile not found')
|
|
405
|
+
}
|
|
406
|
+
return { body: profile }
|
|
407
|
+
})
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Error responses follow the XRPC format:
|
|
411
|
+
|
|
412
|
+
```json
|
|
413
|
+
{
|
|
414
|
+
"error": "NotFound",
|
|
415
|
+
"message": "Profile not found"
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### LexServerAuthError
|
|
420
|
+
|
|
421
|
+
`LexServerAuthError` extends `LexError` with `WWW-Authenticate` header support:
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
import { LexServerAuthError } from '@atproto/lex-server'
|
|
425
|
+
|
|
426
|
+
throw new LexServerAuthError('AuthenticationRequired', 'Invalid credentials', {
|
|
427
|
+
Bearer: { realm: 'api' },
|
|
428
|
+
})
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
This returns a 401 response with the `WWW-Authenticate` header.
|
|
432
|
+
|
|
433
|
+
### Error Handler Callback
|
|
434
|
+
|
|
435
|
+
Use `onHandlerError` to log or report unexpected errors:
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const router = new LexRouter({
|
|
439
|
+
onHandlerError: async ({ error, request, method }) => {
|
|
440
|
+
// Log errors (excluding expected abort signals)
|
|
441
|
+
console.error(`Error in ${method.nsid}:`, error)
|
|
442
|
+
await reportToSentry(error)
|
|
443
|
+
},
|
|
444
|
+
})
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
> [!NOTE]
|
|
448
|
+
>
|
|
449
|
+
> The callback is only invoked for unexpected errors, not for `LexError` instances or request aborts.
|
|
450
|
+
|
|
451
|
+
## Node.js Server
|
|
452
|
+
|
|
453
|
+
The `@atproto/lex-server/nodejs` subpath provides Node.js-specific utilities.
|
|
454
|
+
|
|
455
|
+
### serve()
|
|
456
|
+
|
|
457
|
+
Start a server and begin listening:
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
import { serve } from '@atproto/lex-server/nodejs'
|
|
461
|
+
|
|
462
|
+
const server = await serve(router, { port: 3000 })
|
|
463
|
+
console.log('Server listening on port 3000')
|
|
464
|
+
|
|
465
|
+
// Graceful shutdown
|
|
466
|
+
await server.terminate()
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The server supports `AsyncDisposable`:
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
await using server = await serve(router, { port: 3000 })
|
|
473
|
+
// Server is automatically terminated when scope exits
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Options:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
type StartServerOptions = {
|
|
480
|
+
port?: number
|
|
481
|
+
host?: string
|
|
482
|
+
gracefulTerminationTimeout?: number // ms to wait for connections to close
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### createServer()
|
|
487
|
+
|
|
488
|
+
Create a server without starting it:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
import { createServer } from '@atproto/lex-server/nodejs'
|
|
492
|
+
|
|
493
|
+
const server = createServer(router, {
|
|
494
|
+
gracefulTerminationTimeout: 5000,
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
server.listen(3000, () => {
|
|
498
|
+
console.log('Server listening')
|
|
499
|
+
})
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### toRequestListener()
|
|
503
|
+
|
|
504
|
+
Convert a handler to an Express/Connect-compatible middleware:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
import express from 'express'
|
|
508
|
+
import { toRequestListener } from '@atproto/lex-server/nodejs'
|
|
509
|
+
|
|
510
|
+
const app = express()
|
|
511
|
+
|
|
512
|
+
// Mount the XRPC router
|
|
513
|
+
app.use('/xrpc', toRequestListener(router.handle))
|
|
514
|
+
|
|
515
|
+
app.listen(3000)
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### upgradeWebSocket()
|
|
519
|
+
|
|
520
|
+
Required for WebSocket subscription support in Node.js:
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { LexRouter } from '@atproto/lex-server'
|
|
524
|
+
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
|
|
525
|
+
|
|
526
|
+
const router = new LexRouter({ upgradeWebSocket })
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
## Advanced Usage
|
|
530
|
+
|
|
531
|
+
### Custom Response Objects
|
|
532
|
+
|
|
533
|
+
Return a `Response` object for full control over the response:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
router.add(schema, async ({ params }) => {
|
|
537
|
+
if (params.redirect) {
|
|
538
|
+
return Response.redirect('https://example.com', 302)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return new Response(JSON.stringify({ custom: true }), {
|
|
542
|
+
status: 201,
|
|
543
|
+
headers: {
|
|
544
|
+
'Content-Type': 'application/json',
|
|
545
|
+
'X-Custom-Header': 'value',
|
|
546
|
+
},
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### Response Headers
|
|
552
|
+
|
|
553
|
+
Add headers to responses:
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
router.add(schema, async ({ params }) => {
|
|
557
|
+
return {
|
|
558
|
+
body: { data: 'value' },
|
|
559
|
+
headers: {
|
|
560
|
+
'Cache-Control': 'public, max-age=3600',
|
|
561
|
+
'X-Request-Id': crypto.randomUUID(),
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
})
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Connection Info
|
|
568
|
+
|
|
569
|
+
Access network connection information:
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
router.add(schema, async ({ connection }) => {
|
|
573
|
+
console.log('Remote address:', connection?.remoteAddr?.hostname)
|
|
574
|
+
console.log('Local address:', connection?.localAddr?.hostname)
|
|
575
|
+
return { body: { status: 'ok' } }
|
|
576
|
+
})
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Connection info structure:
|
|
580
|
+
|
|
581
|
+
```typescript
|
|
582
|
+
type ConnectionInfo = {
|
|
583
|
+
localAddr?: {
|
|
584
|
+
hostname: string
|
|
585
|
+
port: number
|
|
586
|
+
transport: 'tcp' | 'udp'
|
|
587
|
+
}
|
|
588
|
+
remoteAddr?: {
|
|
589
|
+
hostname: string
|
|
590
|
+
port: number
|
|
591
|
+
transport: 'tcp' | 'udp'
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
## License
|
|
597
|
+
|
|
598
|
+
MIT or Apache2
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LexError, LexErrorCode } from '@atproto/lex-data';
|
|
2
|
+
import { WWWAuthenticate } from './lib/www-authenticate.js';
|
|
3
|
+
export type { WWWAuthenticate };
|
|
4
|
+
export declare class LexServerAuthError<N extends LexErrorCode = LexErrorCode> extends LexError<N> {
|
|
5
|
+
readonly wwwAuthenticate: WWWAuthenticate;
|
|
6
|
+
name: string;
|
|
7
|
+
constructor(error: N, message: string, wwwAuthenticate?: WWWAuthenticate, options?: ErrorOptions);
|
|
8
|
+
get wwwAuthenticateHeader(): string;
|
|
9
|
+
toJSON(): import("@atproto/lex-data").LexErrorData<any>;
|
|
10
|
+
toResponse(): Response;
|
|
11
|
+
static from(cause: LexError, wwwAuthenticate?: WWWAuthenticate): LexServerAuthError;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAC1D,OAAO,EACL,eAAe,EAEhB,MAAM,2BAA2B,CAAA;AAElC,YAAY,EAAE,eAAe,EAAE,CAAA;AAE/B,qBAAa,kBAAkB,CAC7B,CAAC,SAAS,YAAY,GAAG,YAAY,CACrC,SAAQ,QAAQ,CAAC,CAAC,CAAC;IAMjB,QAAQ,CAAC,eAAe,EAAE,eAAe;IAL3C,IAAI,SAAuB;gBAGzB,KAAK,EAAE,CAAC,EACR,OAAO,EAAE,MAAM,EACN,eAAe,GAAE,eAAoB,EAC9C,OAAO,CAAC,EAAE,YAAY;IAKxB,IAAI,qBAAqB,IAAI,MAAM,CAElC;IAED,MAAM;IAKN,UAAU,IAAI,QAAQ;IAatB,MAAM,CAAC,IAAI,CACT,KAAK,EAAE,QAAQ,EACf,eAAe,CAAC,EAAE,eAAe,GAChC,kBAAkB;CAMtB"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LexServerAuthError = void 0;
|
|
4
|
+
const lex_data_1 = require("@atproto/lex-data");
|
|
5
|
+
const www_authenticate_js_1 = require("./lib/www-authenticate.js");
|
|
6
|
+
class LexServerAuthError extends lex_data_1.LexError {
|
|
7
|
+
wwwAuthenticate;
|
|
8
|
+
name = 'LexServerAuthError';
|
|
9
|
+
constructor(error, message, wwwAuthenticate = {}, options) {
|
|
10
|
+
super(error, message, options);
|
|
11
|
+
this.wwwAuthenticate = wwwAuthenticate;
|
|
12
|
+
}
|
|
13
|
+
get wwwAuthenticateHeader() {
|
|
14
|
+
return (0, www_authenticate_js_1.formatWWWAuthenticateHeader)(this.wwwAuthenticate);
|
|
15
|
+
}
|
|
16
|
+
toJSON() {
|
|
17
|
+
const { cause } = this;
|
|
18
|
+
return cause instanceof lex_data_1.LexError ? cause.toJSON() : super.toJSON();
|
|
19
|
+
}
|
|
20
|
+
toResponse() {
|
|
21
|
+
const { wwwAuthenticateHeader } = this;
|
|
22
|
+
const headers = wwwAuthenticateHeader
|
|
23
|
+
? new Headers({
|
|
24
|
+
'WWW-Authenticate': wwwAuthenticateHeader,
|
|
25
|
+
'Access-Control-Expose-Headers': 'WWW-Authenticate', // CORS
|
|
26
|
+
})
|
|
27
|
+
: undefined;
|
|
28
|
+
return Response.json(this.toJSON(), { status: 401, headers });
|
|
29
|
+
}
|
|
30
|
+
static from(cause, wwwAuthenticate) {
|
|
31
|
+
if (cause instanceof LexServerAuthError)
|
|
32
|
+
return cause;
|
|
33
|
+
return new LexServerAuthError(cause.error, cause.message, wwwAuthenticate, {
|
|
34
|
+
cause,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.LexServerAuthError = LexServerAuthError;
|
|
39
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":";;;AAAA,gDAA0D;AAC1D,mEAGkC;AAIlC,MAAa,kBAEX,SAAQ,mBAAW;IAMR;IALX,IAAI,GAAG,oBAAoB,CAAA;IAE3B,YACE,KAAQ,EACR,OAAe,EACN,kBAAmC,EAAE,EAC9C,OAAsB;QAEtB,KAAK,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,CAAA;QAHrB,oBAAe,GAAf,eAAe,CAAsB;IAIhD,CAAC;IAED,IAAI,qBAAqB;QACvB,OAAO,IAAA,iDAA2B,EAAC,IAAI,CAAC,eAAe,CAAC,CAAA;IAC1D,CAAC;IAED,MAAM;QACJ,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAA;QACtB,OAAO,KAAK,YAAY,mBAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,CAAA;IACpE,CAAC;IAED,UAAU;QACR,MAAM,EAAE,qBAAqB,EAAE,GAAG,IAAI,CAAA;QAEtC,MAAM,OAAO,GAAG,qBAAqB;YACnC,CAAC,CAAC,IAAI,OAAO,CAAC;gBACV,kBAAkB,EAAE,qBAAqB;gBACzC,+BAA+B,EAAE,kBAAkB,EAAE,OAAO;aAC7D,CAAC;YACJ,CAAC,CAAC,SAAS,CAAA;QAEb,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAA;IAC/D,CAAC;IAED,MAAM,CAAC,IAAI,CACT,KAAe,EACf,eAAiC;QAEjC,IAAI,KAAK,YAAY,kBAAkB;YAAE,OAAO,KAAK,CAAA;QACrD,OAAO,IAAI,kBAAkB,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,eAAe,EAAE;YACzE,KAAK;SACN,CAAC,CAAA;IACJ,CAAC;CACF;AA7CD,gDA6CC","sourcesContent":["import { LexError, LexErrorCode } from '@atproto/lex-data'\nimport {\n WWWAuthenticate,\n formatWWWAuthenticateHeader,\n} from './lib/www-authenticate.js'\n\nexport type { WWWAuthenticate }\n\nexport class LexServerAuthError<\n N extends LexErrorCode = LexErrorCode,\n> extends LexError<N> {\n name = 'LexServerAuthError'\n\n constructor(\n error: N,\n message: string,\n readonly wwwAuthenticate: WWWAuthenticate = {},\n options?: ErrorOptions,\n ) {\n super(error, message, options)\n }\n\n get wwwAuthenticateHeader(): string {\n return formatWWWAuthenticateHeader(this.wwwAuthenticate)\n }\n\n toJSON() {\n const { cause } = this\n return cause instanceof LexError ? cause.toJSON() : super.toJSON()\n }\n\n toResponse(): Response {\n const { wwwAuthenticateHeader } = this\n\n const headers = wwwAuthenticateHeader\n ? new Headers({\n 'WWW-Authenticate': wwwAuthenticateHeader,\n 'Access-Control-Expose-Headers': 'WWW-Authenticate', // CORS\n })\n : undefined\n\n return Response.json(this.toJSON(), { status: 401, headers })\n }\n\n static from(\n cause: LexError,\n wwwAuthenticate?: WWWAuthenticate,\n ): LexServerAuthError {\n if (cause instanceof LexServerAuthError) return cause\n return new LexServerAuthError(cause.error, cause.message, wwwAuthenticate, {\n cause,\n })\n }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"example.d.ts","sourceRoot":"","sources":["../src/example.ts"],"names":[],"mappings":""}
|