@atproto/lex-server 0.1.4 → 0.1.5

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/src/lex-router.ts DELETED
@@ -1,1219 +0,0 @@
1
- import { encode } from '@atproto/lex-cbor'
2
- import {
3
- LexError,
4
- LexErrorData,
5
- LexValue,
6
- isPlainObject,
7
- ui8Concat,
8
- } from '@atproto/lex-data'
9
- import { lexParse, lexToJson } from '@atproto/lex-json'
10
- import {
11
- DidString,
12
- InferMethodInput,
13
- InferMethodMessage,
14
- InferMethodOutput,
15
- InferMethodOutputBody,
16
- InferMethodOutputEncoding,
17
- InferMethodParams,
18
- Main,
19
- NsidString,
20
- Procedure,
21
- Query,
22
- Subscription,
23
- getMain,
24
- isDidString,
25
- isNsidString,
26
- } from '@atproto/lex-schema'
27
- import { LexServerError } from './errors.js'
28
- import { drainWebsocket } from './lib/drain-websocket.js'
29
-
30
- const XRPC_PATH_PREFIX = '/xrpc/'
31
- const XRPC_HEALTH_CHECK_PATH = '/xrpc/_health'
32
-
33
- type Awaitable<T> = T | Promise<T>
34
-
35
- /**
36
- * Union type representing the supported Lexicon method types.
37
- *
38
- * - `Query`: Read-only methods invoked via HTTP GET
39
- * - `Procedure`: Methods that may modify state, invoked via HTTP POST
40
- * - `Subscription`: Real-time streaming methods over WebSocket
41
- */
42
- export type LexMethod = Query | Procedure | Subscription
43
-
44
- /**
45
- * Network address for TCP or UDP connections.
46
- *
47
- * @example
48
- * ```typescript
49
- * const addr: NetAddr = {
50
- * hostname: '127.0.0.1',
51
- * port: 3000,
52
- * transport: 'tcp'
53
- * }
54
- * ```
55
- */
56
- export type NetAddr = {
57
- /** The hostname or IP address of the connection. */
58
- hostname: string
59
- /** The port number of the connection. */
60
- port: number
61
- /** The transport protocol used. */
62
- transport: 'tcp' | 'udp'
63
- }
64
-
65
- /**
66
- * Unix domain socket address.
67
- *
68
- * @example
69
- * ```typescript
70
- * const addr: UnixAddr = {
71
- * path: '/var/run/app.sock',
72
- * transport: 'unix'
73
- * }
74
- * ```
75
- */
76
- export type UnixAddr = {
77
- /** The filesystem path to the Unix socket. */
78
- path: string
79
- /** The transport protocol used. */
80
- transport: 'unix' | 'unixpacket'
81
- }
82
-
83
- /**
84
- * Union type for all supported address types.
85
- *
86
- * Can be a network address ({@link NetAddr}), Unix socket address ({@link UnixAddr}),
87
- * or `undefined` when the address is not available.
88
- */
89
- export type Addr = NetAddr | UnixAddr | undefined
90
-
91
- /**
92
- * Metadata about the client connection for an incoming request.
93
- *
94
- * @typeParam A - The address type, defaults to {@link Addr}
95
- *
96
- * @example
97
- * ```typescript
98
- * const info: ConnectionInfo<NetAddr> = {
99
- * remoteAddr: { hostname: '192.168.1.1', port: 54321, transport: 'tcp' },
100
- * completed: new Promise((resolve) => socket.on('close', resolve))
101
- * }
102
- * ```
103
- */
104
- export type ConnectionInfo<A extends Addr = Addr> = {
105
- /** The remote address of the client, if available. */
106
- remoteAddr: A
107
- /** Promise that resolves when the connection is fully closed. */
108
- completed: Promise<void>
109
- }
110
-
111
- /**
112
- * Function signature for handling HTTP requests in the XRPC router.
113
- *
114
- * This is the standard fetch-style handler that processes incoming requests
115
- * and returns responses. It is used both internally by the router and can
116
- * be used to integrate with other HTTP frameworks.
117
- *
118
- * @param request - The incoming HTTP request
119
- * @param connection - Optional connection metadata including remote address
120
- * @returns A promise resolving to the HTTP response
121
- *
122
- * @example
123
- * ```typescript
124
- * const handler: FetchHandler = async (request, connection) => {
125
- * console.log('Request from:', connection?.remoteAddr)
126
- * return new Response('Hello, World!')
127
- * }
128
- * ```
129
- */
130
- export type FetchHandler = (
131
- request: Request,
132
- connection?: ConnectionInfo,
133
- ) => Promise<Response>
134
-
135
- /**
136
- * Context object passed to XRPC method handlers.
137
- *
138
- * Contains all the information needed to process a request, including
139
- * parsed parameters, authentication credentials, and the raw request object.
140
- *
141
- * @typeParam Method - The Lexicon method type (Query, Procedure, or Subscription)
142
- * @typeParam Credentials - The type of authentication credentials, determined by the auth handler
143
- *
144
- * @example
145
- * ```typescript
146
- * const handler: LexRouterMethodHandler<MyMethod, UserCredentials> = async (ctx) => {
147
- * const { credentials, params, input, signal } = ctx
148
- * // credentials.userId is available if auth handler returns UserCredentials
149
- * // params contains validated query parameters
150
- * // input contains the request body (for procedures)
151
- * // signal can be used to abort long-running operations
152
- * return { body: { result: 'success' } }
153
- * }
154
- * ```
155
- */
156
- export type LexRouterHandlerContext<Method extends LexMethod, Credentials> = {
157
- /** Authentication credentials returned by the auth handler. */
158
- credentials: Credentials
159
- /** Parsed and validated request input (body for procedures, undefined for queries). */
160
- input: InferMethodInput<Method, Body>
161
- /** Parsed and validated URL query parameters. */
162
- params: InferMethodParams<Method>
163
- /** The original HTTP request object. */
164
- request: Request
165
- /** Abort signal that is triggered when the request is cancelled. */
166
- signal: AbortSignal
167
- /** Connection metadata including remote address. */
168
- connection?: ConnectionInfo
169
- }
170
-
171
- type AsOptionalPayloadOptions<T> = T extends undefined | void
172
- ? { encoding?: undefined; body?: undefined }
173
- : T
174
-
175
- /**
176
- * Return type for XRPC method handlers (queries and procedures).
177
- *
178
- * Handlers can return either:
179
- * - A raw {@link Response} object for full control over the HTTP response
180
- * - An object with `body`, optional `encoding`, and optional `headers`
181
- *
182
- * For JSON methods, the body is automatically serialized. For other encodings,
183
- * the body must be a valid {@link BodyInit} type.
184
- *
185
- * @typeParam Method - The Lexicon method type (Query or Procedure)
186
- *
187
- * @example
188
- * ```typescript
189
- * // Return JSON body (most common)
190
- * return { body: { users: [...] } }
191
- *
192
- * // Return with custom headers
193
- * return {
194
- * body: { data: 'value' },
195
- * headers: { 'Cache-Control': 'max-age=3600' }
196
- * }
197
- *
198
- * // Return raw Response for full control
199
- * return new Response(binaryData, {
200
- * headers: { 'Content-Type': 'application/octet-stream' }
201
- * })
202
- * ```
203
- */
204
- export type LexRouterHandlerOutput<Method extends Query | Procedure> =
205
- | Response
206
- | ({
207
- headers?: HeadersInit
208
- } & (InferMethodOutputEncoding<Method> extends 'application/json'
209
- ? {
210
- // Allow omitting body when output is JSON
211
- encoding?: 'application/json'
212
- body: InferMethodOutputBody<Method>
213
- }
214
- : AsOptionalPayloadOptions<InferMethodOutput<Method, BodyInit>>))
215
-
216
- /**
217
- * Handler function for XRPC query and procedure methods.
218
- *
219
- * Receives a context object with request details and credentials,
220
- * and returns either a Response or a structured output object.
221
- *
222
- * @typeParam Method - The Lexicon method type (Query or Procedure)
223
- * @typeParam Credentials - The type of authentication credentials
224
- *
225
- * @example
226
- * ```typescript
227
- * const getProfile: LexRouterMethodHandler<GetProfileMethod, UserCredentials> = async (ctx) => {
228
- * const profile = await db.getProfile(ctx.params.actor)
229
- * return { body: profile }
230
- * }
231
- * ```
232
- */
233
- export type LexRouterMethodHandler<
234
- Method extends Query | Procedure = Query | Procedure,
235
- Credentials = unknown,
236
- > = (
237
- ctx: LexRouterHandlerContext<Method, Credentials>,
238
- ) => Awaitable<LexRouterHandlerOutput<Method>>
239
-
240
- /**
241
- * Configuration object for registering an XRPC method with authentication.
242
- *
243
- * Used when you need to specify both a handler and an auth function.
244
- *
245
- * @typeParam Method - The Lexicon method type (Query or Procedure)
246
- * @typeParam Credentials - The type of authentication credentials
247
- *
248
- * @example
249
- * ```typescript
250
- * const config: LexRouterMethodConfig<GetProfileMethod, UserCredentials> = {
251
- * handler: async (ctx) => {
252
- * return { body: await getProfile(ctx.params.actor) }
253
- * },
254
- * auth: async ({ request }) => {
255
- * return verifyToken(request.headers.get('authorization'))
256
- * }
257
- * }
258
- * ```
259
- */
260
- export type LexRouterMethodConfig<
261
- Method extends Query | Procedure = Query | Procedure,
262
- Credentials = unknown,
263
- > = {
264
- /** The handler function that processes the request. */
265
- handler: LexRouterMethodHandler<Method, Credentials>
266
- /** Authentication function that validates credentials before the handler runs. */
267
- auth: LexRouterAuth<Credentials, Method>
268
- }
269
-
270
- /**
271
- * Handler function for XRPC subscription methods (WebSocket streams).
272
- *
273
- * Returns an async iterable that yields messages to be sent over the WebSocket.
274
- * The connection remains open until the iterable completes or an error occurs.
275
- *
276
- * @typeParam Method - The Lexicon subscription method type
277
- * @typeParam Credentials - The type of authentication credentials
278
- *
279
- * @example
280
- * ```typescript
281
- * const subscribeRepos: LexRouterSubscriptionHandler<SubscribeReposMethod> = async function* (ctx) {
282
- * const cursor = ctx.params.cursor ?? 0
283
- * for await (const event of eventStream.since(cursor)) {
284
- * if (ctx.signal.aborted) break
285
- * yield { $type: 'com.atproto.sync.subscribeRepos#commit', ...event }
286
- * }
287
- * }
288
- * ```
289
- */
290
- export type LexRouterSubscriptionHandler<
291
- Method extends Subscription = Subscription,
292
- Credentials = unknown,
293
- > = (
294
- ctx: LexRouterHandlerContext<Method, Credentials>,
295
- ) => AsyncIterable<InferMethodMessage<Method>>
296
-
297
- /**
298
- * Configuration object for registering an XRPC subscription with authentication.
299
- *
300
- * Used when you need to specify both a handler and an auth function for subscriptions.
301
- *
302
- * @typeParam Method - The Lexicon subscription method type
303
- * @typeParam Credentials - The type of authentication credentials
304
- *
305
- * @example
306
- * ```typescript
307
- * const config: LexRouterSubscriptionConfig<SubscribeReposMethod, ServiceCredentials> = {
308
- * handler: async function* (ctx) {
309
- * for await (const event of eventStream) {
310
- * yield event
311
- * }
312
- * },
313
- * auth: async ({ request }) => {
314
- * return verifyServiceAuth(request)
315
- * }
316
- * }
317
- * ```
318
- */
319
- export type LexRouterSubscriptionConfig<
320
- Method extends Subscription = Subscription,
321
- Credentials = unknown,
322
- > = {
323
- /** The handler function that yields subscription messages. */
324
- handler: LexRouterSubscriptionHandler<Method, Credentials>
325
- /** Authentication function that validates credentials before the handler runs. */
326
- auth: LexRouterAuth<Credentials, Method>
327
- }
328
-
329
- /**
330
- * Context object passed to authentication handlers.
331
- *
332
- * Contains the information needed to authenticate a request before
333
- * the main handler is invoked.
334
- *
335
- * @typeParam Method - The Lexicon method type
336
- *
337
- * @example
338
- * ```typescript
339
- * const authHandler: LexRouterAuth<UserCredentials> = async (ctx) => {
340
- * const token = ctx.request.headers.get('authorization')
341
- * if (!token) throw new LexServerAuthError('AuthenticationRequired', 'Missing token')
342
- * return { userId: await verifyToken(token) }
343
- * }
344
- * ```
345
- */
346
- export type LexRouterAuthContext<Method extends LexMethod = LexMethod> = {
347
- /** The Lexicon method definition being called. */
348
- method: Method
349
- /** Parsed and validated URL query parameters. */
350
- params: InferMethodParams<Method>
351
- /** The original HTTP request object. */
352
- request: Request
353
- /** Connection metadata including remote address. */
354
- connection?: ConnectionInfo
355
- }
356
-
357
- /**
358
- * Authentication handler function for XRPC methods.
359
- *
360
- * Called before the main handler to validate authentication credentials.
361
- * Should return the validated credentials or throw an error if authentication fails.
362
- *
363
- * @typeParam Credentials - The type of credentials to return on success
364
- * @typeParam Method - The Lexicon method type
365
- *
366
- * @example
367
- * ```typescript
368
- * // Simple token-based auth
369
- * const tokenAuth: LexRouterAuth<{ userId: string }> = async ({ request }) => {
370
- * const token = request.headers.get('authorization')?.replace('Bearer ', '')
371
- * if (!token) throw new LexServerAuthError('AuthenticationRequired', 'Token required')
372
- * const userId = await verifyToken(token)
373
- * if (!userId) throw new LexServerAuthError('AuthenticationRequired', 'Invalid token')
374
- * return { userId }
375
- * }
376
- *
377
- * // Using with serviceAuth for AT Protocol service authentication
378
- * import { serviceAuth } from '@atproto/lex-server'
379
- * const auth = serviceAuth({ audience: 'did:web:example.com', unique: checkNonce })
380
- * ```
381
- */
382
- export type LexRouterAuth<
383
- Credentials = unknown,
384
- Method extends LexMethod = LexMethod,
385
- > = (ctx: LexRouterAuthContext<Method>) => Credentials | Promise<Credentials>
386
-
387
- /**
388
- * Context object passed to error handler callbacks.
389
- *
390
- * Used for logging and monitoring errors that occur during request handling.
391
- */
392
- export type HandlerErrorContext = {
393
- request: Request
394
- method: LexMethod
395
- error: LexServerError
396
- }
397
-
398
- export type HandlerErrorHook = (
399
- ctx: HandlerErrorContext,
400
- ) => void | Promise<void>
401
-
402
- export type SocketErrorContext = {
403
- request: Request
404
- method: Subscription
405
- error: unknown
406
- }
407
-
408
- export type SocketErrorHook = (ctx: SocketErrorContext) => void | Promise<void>
409
-
410
- /**
411
- * Function that upgrades an HTTP request to a WebSocket connection.
412
- *
413
- * This is platform-specific: Deno provides this natively, while Node.js
414
- * requires the `upgradeWebSocket` function from this package.
415
- *
416
- * @param request - The HTTP request to upgrade
417
- * @returns An object containing the WebSocket and the upgrade response
418
- *
419
- * @example
420
- * ```typescript
421
- * // In Node.js, use the provided upgradeWebSocket function
422
- * import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
423
- *
424
- * const router = new LexRouter({ upgradeWebSocket })
425
- * ```
426
- */
427
- export type UpgradeWebSocket = (request: Request) => {
428
- /** The WebSocket instance for bidirectional communication. */
429
- socket: WebSocket
430
- /** The HTTP response to return (101 Switching Protocols). */
431
- response: Response
432
- }
433
-
434
- export type HealthCheckHandler = (
435
- request: Request,
436
- ) => Awaitable<{ [x: string]: unknown; status: 'ok' }>
437
-
438
- /**
439
- * Configuration options for the {@link LexRouter}.
440
- *
441
- * @example
442
- * ```typescript
443
- * const options: LexRouterOptions = {
444
- * upgradeWebSocket,
445
- * onHandlerError: async ({ error, request, method }) => {
446
- * console.error(`Error in ${method.nsid}:`, error)
447
- * await reportToSentry(error)
448
- * },
449
- * highWaterMark: 64 * 1024, // 64KB
450
- * lowWaterMark: 16 * 1024 // 16KB
451
- * }
452
- * ```
453
- */
454
- export type LexRouterOptions = {
455
- /**
456
- * Function to upgrade HTTP requests to WebSocket connections. Required for
457
- * subscription methods. Defaults to Deno's built-in
458
- * {@link globalThis.upgradeWebSocket} if available. For NodeJS, use the
459
- * homonymous export from `@atproto/lex-server/nodejs`.
460
- */
461
- upgradeWebSocket?: UpgradeWebSocket
462
- /**
463
- * Callback invoked when an error occurs during request handling. Useful for
464
- * logging and error reporting. Not called for client-induced errors (e.g.,
465
- * request abortion).
466
- */
467
- onHandlerError?: HandlerErrorHook
468
- /**
469
- * Optional hook for handling errors during generation of WebSocket messages.
470
- */
471
- onSocketError?: SocketErrorHook
472
- /**
473
- * Optional health check handler. If provided, this function will be called
474
- * for requests to the /xrpc/_health endpoint, allowing for custom health
475
- * check logic and responses.
476
- *
477
- * If not provided, the server will respond to /xrpc/_health requests with a
478
- * default JSON response of `{ status: 'ok' }`.
479
- */
480
- healthCheck?: HealthCheckHandler
481
- /**
482
- * Optional fallback handler for requests that are not /xrpc/ paths. Can be
483
- * used to serve static files or other routes. If not provided, non-/xrpc/
484
- * requests will return 404 responses.
485
- */
486
- fallback?: FetchHandler
487
- /**
488
- * High water mark for WebSocket backpressure (in bytes). When buffered data
489
- * exceeds this, the handler will wait before sending more.
490
- */
491
- highWaterMark?: number
492
- /**
493
- * Low water mark for WebSocket backpressure (in bytes). The handler resumes
494
- * sending when buffered data drops below this.
495
- */
496
- lowWaterMark?: number
497
- }
498
-
499
- /**
500
- * XRPC router for handling AT Protocol Lexicon methods.
501
- *
502
- * The router handles HTTP routing, parameter parsing, input validation,
503
- * authentication, and response serialization for XRPC methods. It supports
504
- * queries (GET), procedures (POST), and subscriptions (WebSocket).
505
- *
506
- * @example Setting up a basic XRPC server
507
- * ```typescript
508
- * import { LexRouter } from '@atproto/lex-server'
509
- * import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
510
- * import { getProfile, createPost, subscribeRepos } from './lexicons.js'
511
- *
512
- * const router = new LexRouter({ upgradeWebSocket })
513
- *
514
- * // Register a query handler (GET request)
515
- * router.add(getProfile, async (ctx) => {
516
- * const profile = await db.getProfile(ctx.params.actor)
517
- * return { body: profile }
518
- * })
519
- *
520
- * // Register a procedure handler with authentication (POST request)
521
- * router.add(createPost, {
522
- * handler: async (ctx) => {
523
- * const post = await db.createPost(ctx.credentials.did, ctx.input.body)
524
- * return { body: { uri: post.uri, cid: post.cid } }
525
- * },
526
- * auth: async ({ request }) => {
527
- * return verifyAccessToken(request)
528
- * }
529
- * })
530
- *
531
- * // Register a subscription handler (WebSocket)
532
- * router.add(subscribeRepos, async function* (ctx) {
533
- * for await (const event of eventStream.since(ctx.params.cursor)) {
534
- * if (ctx.signal.aborted) break
535
- * yield event
536
- * }
537
- * })
538
- *
539
- * // Start the server
540
- * const server = await serve(router, { port: 3000 })
541
- * console.log('XRPC server listening on port 3000')
542
- * ```
543
- *
544
- * @example Using with service authentication
545
- * ```typescript
546
- * import { LexRouter, serviceAuth } from '@atproto/lex-server'
547
- *
548
- * const router = new LexRouter()
549
- *
550
- * const auth = serviceAuth({
551
- * audience: 'did:web:api.example.com',
552
- * unique: async (nonce) => {
553
- * // Check and record nonce uniqueness
554
- * return await nonceStore.checkAndAdd(nonce)
555
- * }
556
- * })
557
- *
558
- * router.add(protectedMethod, {
559
- * handler: async (ctx) => {
560
- * // ctx.credentials contains { did, didDocument, jwt }
561
- * return { body: { callerDid: ctx.credentials.did } }
562
- * },
563
- * auth
564
- * })
565
- * ```
566
- */
567
- export class LexRouter {
568
- /** Map of NSID strings to their fetch handlers. */
569
- readonly handlers: Map<NsidString, FetchHandler> = new Map()
570
-
571
- /**
572
- * Creates a new XRPC router.
573
- *
574
- * @param options - Router configuration options
575
- */
576
- constructor(readonly options: LexRouterOptions = {}) {}
577
-
578
- /**
579
- * Registers a subscription handler without authentication.
580
- *
581
- * @param ns - The Lexicon namespace definition for the subscription
582
- * @param handler - Async generator function that yields subscription messages
583
- * @returns This router instance for chaining
584
- */
585
- add<M extends Subscription>(
586
- ns: Main<M>,
587
- handler: LexRouterSubscriptionHandler<M, void>,
588
- ): this
589
- /**
590
- * Registers a subscription handler with authentication.
591
- *
592
- * @param ns - The Lexicon namespace definition for the subscription
593
- * @param config - Configuration object with handler and auth function
594
- * @returns This router instance for chaining
595
- */
596
- add<M extends Subscription, Credentials>(
597
- ns: Main<M>,
598
- config: LexRouterSubscriptionConfig<M, Credentials>,
599
- ): this
600
- /**
601
- * Registers a query or procedure handler without authentication.
602
- *
603
- * @param ns - The Lexicon namespace definition for the method
604
- * @param handler - Handler function that processes requests
605
- * @returns This router instance for chaining
606
- */
607
- add<M extends Query | Procedure>(
608
- ns: Main<M>,
609
- handler: LexRouterMethodHandler<M, void>,
610
- ): this
611
- /**
612
- * Registers a query or procedure handler with authentication.
613
- *
614
- * @param ns - The Lexicon namespace definition for the method
615
- * @param config - Configuration object with handler and auth function
616
- * @returns This router instance for chaining
617
- */
618
- add<M extends Query | Procedure, Credentials>(
619
- ns: Main<M>,
620
- config: LexRouterMethodConfig<M, Credentials>,
621
- ): this
622
- /**
623
- * Registers a Lexicon method handler.
624
- *
625
- * This is the unified overload that accepts any method type with optional authentication.
626
- *
627
- * @param ns - The Lexicon namespace definition
628
- * @param config - Handler function or configuration object
629
- * @returns This router instance for chaining
630
- *
631
- * @throws {TypeError} If a method with the same NSID is already registered
632
- *
633
- * @example
634
- * ```typescript
635
- * // Register without auth (credentials will be void)
636
- * router.add(myQuery, async (ctx) => {
637
- * return { body: { data: 'value' } }
638
- * })
639
- *
640
- * // Register with auth
641
- * router.add(myProcedure, {
642
- * handler: async (ctx) => {
643
- * console.log('Caller:', ctx.credentials.userId)
644
- * return { body: { success: true } }
645
- * },
646
- * auth: async ({ request }) => ({ userId: await verifyToken(request) })
647
- * })
648
- * ```
649
- */
650
- add<M extends LexMethod, Credentials = unknown>(
651
- ns: Main<M>,
652
- config: M extends Subscription
653
- ?
654
- | LexRouterSubscriptionHandler<M, Credentials>
655
- | LexRouterSubscriptionConfig<M, Credentials>
656
- : M extends Query | Procedure
657
- ?
658
- | LexRouterMethodHandler<M, Credentials>
659
- | LexRouterMethodConfig<M, Credentials>
660
- : never,
661
- ): this
662
- add<M extends LexMethod>(
663
- ns: Main<M>,
664
- config:
665
- | LexRouterSubscriptionHandler<any, any>
666
- | LexRouterSubscriptionConfig<any, any>
667
- | LexRouterMethodHandler<any, any>
668
- | LexRouterMethodConfig<any, any>,
669
- ) {
670
- const method = getMain(ns)
671
- const nsid = normalizeNsid(method.nsid)
672
-
673
- if (this.handlers.has(nsid)) {
674
- throw new TypeError(`Method ${method.nsid} already registered`)
675
- }
676
-
677
- const methodConfig =
678
- typeof config === 'function'
679
- ? { handler: config, auth: undefined }
680
- : config
681
-
682
- const handler: FetchHandler =
683
- method.type === 'subscription'
684
- ? this.buildSubscriptionHandler(
685
- method,
686
- methodConfig.handler as LexRouterSubscriptionHandler<any, any>,
687
- methodConfig.auth,
688
- )
689
- : this.buildMethodHandler(
690
- method,
691
- methodConfig.handler as LexRouterMethodHandler<any, any>,
692
- methodConfig.auth,
693
- )
694
-
695
- this.handlers.set(nsid, handler)
696
-
697
- return this
698
- }
699
-
700
- private buildMethodHandler<Method extends Query | Procedure, Credentials>(
701
- method: Method,
702
- methodHandler: LexRouterMethodHandler<Method, Credentials>,
703
- auth?: LexRouterAuth<Credentials, Method>,
704
- ): FetchHandler {
705
- const getInput = (
706
- method.type === 'procedure'
707
- ? getProcedureInput.bind(method)
708
- : getQueryInput.bind(method)
709
- ) as (request: Request) => Promise<InferMethodInput<Method, Body>>
710
-
711
- return async (request, connection) => {
712
- // @NOTE CORS requests should be handled by a middleware before reaching
713
- // this point.
714
- if (
715
- (method.type === 'procedure' && request.method !== 'POST') ||
716
- (method.type === 'query' &&
717
- request.method !== 'GET' &&
718
- request.method !== 'HEAD')
719
- ) {
720
- return invalidRequestResponse('Method not allowed', 405)
721
- }
722
-
723
- try {
724
- const url = new URL(request.url)
725
- const params = method.parameters.fromURLSearchParams(
726
- url.searchParams,
727
- ) as InferMethodParams<Method>
728
-
729
- const credentials = auth
730
- ? await auth({ method, params, request, connection })
731
- : (undefined as Credentials)
732
-
733
- const input = await getInput(request)
734
-
735
- const output = await methodHandler({
736
- credentials,
737
- params,
738
- input,
739
- request,
740
- connection,
741
- signal: request.signal,
742
- })
743
-
744
- if (output instanceof Response) {
745
- return output
746
- }
747
-
748
- // @TODO add validation of output based on method.output.schema?
749
-
750
- if (output.body === undefined && output.encoding === undefined) {
751
- return new Response(null, { status: 200, headers: output.headers })
752
- }
753
-
754
- if (method.output?.encoding === 'application/json') {
755
- return Response.json(lexToJson(output.body as LexValue), {
756
- status: 200,
757
- headers: output.headers,
758
- })
759
- }
760
-
761
- const headers = new Headers(output.headers)
762
- headers.set('content-type', output.encoding!)
763
- return new Response(output.body as BodyInit | null | undefined, {
764
- status: 200,
765
- headers,
766
- })
767
- } catch (error) {
768
- return this.handlerError(request, method, error)
769
- }
770
- }
771
- }
772
-
773
- private buildSubscriptionHandler<Method extends Subscription, Credentials>(
774
- method: Method,
775
- methodHandler: LexRouterSubscriptionHandler<Method, Credentials>,
776
- auth?: LexRouterAuth<Credentials, Method>,
777
- ): FetchHandler {
778
- const {
779
- onSocketError,
780
- upgradeWebSocket = (globalThis as any).Deno?.upgradeWebSocket as
781
- | UpgradeWebSocket
782
- | undefined,
783
- } = this.options
784
- if (!upgradeWebSocket) {
785
- throw new TypeError(
786
- 'WebSocket upgrade not supported in this environment. Please provide an upgradeWebSocket option when creating the LexRouter.',
787
- )
788
- }
789
-
790
- return async (request, connection) => {
791
- if (request.method !== 'GET') {
792
- return invalidRequestResponse('Method not allowed', 405)
793
- }
794
-
795
- if (
796
- request.headers.get('connection')?.toLowerCase() !== 'upgrade' ||
797
- request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
798
- ) {
799
- return invalidRequestResponse(
800
- 'XRPC subscriptions are only available over WebSocket',
801
- 426,
802
- {
803
- Connection: 'Upgrade',
804
- Upgrade: 'websocket',
805
- },
806
- )
807
- }
808
-
809
- if (request.signal.aborted) {
810
- return invalidRequestResponse('Request aborted', 499)
811
- }
812
-
813
- try {
814
- const { response, socket } = upgradeWebSocket(request)
815
-
816
- // @NOTE We are using a distinct signal than request.signal because that
817
- // signal may get aborted before the WebSocket is closed (this is the
818
- // case with Deno).
819
- const abortController = new AbortController()
820
- const { signal } = abortController
821
- const abort = () => abortController.abort()
822
-
823
- const onOpen = async () => {
824
- try {
825
- const url = new URL(request.url)
826
- const params = method.parameters.fromURLSearchParams(
827
- url.searchParams,
828
- ) as InferMethodParams<Method>
829
-
830
- const credentials: Credentials = auth
831
- ? await auth({ method, params, request, connection })
832
- : (undefined as Credentials)
833
-
834
- signal.throwIfAborted()
835
-
836
- const iterable = methodHandler({
837
- credentials,
838
- params,
839
- input: undefined as InferMethodInput<Method, Body>,
840
- request,
841
- connection,
842
- signal,
843
- })
844
-
845
- const iterator = iterable[Symbol.asyncIterator]()
846
-
847
- if (iterator.return) {
848
- signal.addEventListener(
849
- 'abort',
850
- () => {
851
- // @NOTE if iterator.return() throws, and no onSocketError is
852
- // provided, or if onSocketError itself throws, the error will
853
- // be unhandled, causing the process to crash. This is
854
- // intentional, as it surfaces critical errors that occur
855
- // during cleanup of the subscription.
856
-
857
- void new Promise((resolve) => {
858
- // Wrapping in new Promise to catch any potential sync errors thrown by iterator.return()
859
- resolve(iterator.return!())
860
- }).catch(
861
- onSocketError
862
- ? (error) => onSocketError({ request, method, error })
863
- : null,
864
- )
865
- },
866
- {
867
- once: true,
868
- },
869
- )
870
- }
871
-
872
- while (!signal.aborted && socket.readyState === 1) {
873
- const result = await iterator.next()
874
- if (result.done) break
875
-
876
- // @TODO add validation of output based on method.output.schema?
877
-
878
- const data = encodeMessageFrame(method, result.value)
879
-
880
- socket.send(data)
881
-
882
- // Apply backpressure by waiting for the buffered data to drain
883
- // before generating the next message
884
- await drainWebsocket(socket, signal, this.options)
885
- }
886
-
887
- if (socket.readyState === 1) {
888
- socket.close(1000)
889
- }
890
- } catch (error) {
891
- // If the socket is still open, send an error frame before closing
892
- if (socket.readyState === 1) {
893
- const isLexError = error instanceof LexError
894
-
895
- // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
896
- const code =
897
- isLexError && method.errors?.includes(error.error)
898
- ? 1008 // Policy Violation for known LexErrors
899
- : 1011 // Internal Error for unexpected errors
900
-
901
- if (isLexError) {
902
- socket.send(encodeErrorFrame(error.toJSON()))
903
- socket.close(code, error.error)
904
- } else {
905
- const error = 'InternalServerError'
906
- const message = 'An internal error occurred'
907
- socket.send(encodeErrorFrame({ error, message }))
908
- socket.close(code, error)
909
- }
910
- }
911
-
912
- if (onSocketError && !isAbortReason(signal, error)) {
913
- await onSocketError({ request, method, error })
914
- }
915
- } finally {
916
- abortController.abort()
917
- }
918
- }
919
-
920
- socket.addEventListener('error', abort)
921
- socket.addEventListener('close', abort)
922
- socket.addEventListener('open', onOpen)
923
- socket.addEventListener('message', onMessage)
924
-
925
- return response
926
- } catch (error) {
927
- return this.handlerError(request, method, error)
928
- }
929
- }
930
- }
931
-
932
- private async handlerError(
933
- request: Request,
934
- method: LexMethod,
935
- cause: unknown,
936
- ) {
937
- // Only report unexpected processing errors
938
- if (isAbortReason(request.signal, cause)) {
939
- return Response.json({ error: 'RequestAborted' }, { status: 499 })
940
- }
941
-
942
- const error = LexServerError.from(cause)
943
-
944
- const { onHandlerError } = this.options
945
- if (onHandlerError) await onHandlerError({ error, request, method })
946
-
947
- return error.toResponse()
948
- }
949
-
950
- /**
951
- * The main fetch handler for processing XRPC requests.
952
- *
953
- * Routes incoming requests to the appropriate method handler based on the
954
- * NSID in the URL path. Returns appropriate error responses for invalid
955
- * paths or unimplemented methods.
956
- *
957
- * This handler can be used directly with HTTP servers that support the
958
- * fetch API pattern, or converted to a Node.js request listener using
959
- * `toRequestListener()`.
960
- *
961
- * @param request - The incoming HTTP request
962
- * @param connection - Optional connection metadata
963
- * @returns A promise resolving to the HTTP response
964
- *
965
- * @example
966
- * ```typescript
967
- * // Use with Deno
968
- * Deno.serve(router.fetch)
969
- *
970
- * // Use with Bun
971
- * Bun.serve({ fetch: router.fetch })
972
- *
973
- * // Use with Node.js
974
- * import { toRequestListener } from '@atproto/lex-server/nodejs'
975
- * const listener = toRequestListener(router.fetch)
976
- * http.createServer(listener).listen(3000)
977
- * ```
978
- */
979
- fetch: FetchHandler = async (
980
- request: Request,
981
- connection?: ConnectionInfo,
982
- ): Promise<Response> => {
983
- const { pathname } = new URL(request.url)
984
- const atprotoProxy = request.headers.get('atproto-proxy')
985
-
986
- if (!pathname.startsWith(XRPC_PATH_PREFIX)) {
987
- // Handle non XRPC paths
988
- const { fallback } = this.options
989
- if (fallback) return fallback(request, connection)
990
- return new Response('Not Found', { status: 404 })
991
- }
992
-
993
- if (pathname === XRPC_HEALTH_CHECK_PATH) {
994
- if (request.method !== 'GET') {
995
- return invalidRequestResponse('Method not allowed', 405)
996
- }
997
- if (atprotoProxy != null) {
998
- return invalidRequestResponse(
999
- 'atproto-proxy header is not allowed on health check endpoint',
1000
- )
1001
- }
1002
-
1003
- const { healthCheck } = this.options
1004
- const data = healthCheck ? await healthCheck(request) : { status: 'ok' }
1005
-
1006
- return Response.json(data)
1007
- }
1008
-
1009
- const subPath = pathname.slice(XRPC_PATH_PREFIX.length)
1010
-
1011
- if (!isNsidString(subPath)) {
1012
- return invalidRequestResponse('Invalid NSID in URL path')
1013
- }
1014
-
1015
- const nsid = normalizeNsid(subPath)
1016
- if (atprotoProxy == null) {
1017
- const handler = this.handlers.get(nsid)
1018
- if (handler) return handler(request, connection)
1019
- } else {
1020
- // Handle service proxying logic.
1021
-
1022
- const proxyInfo = parseAtprotoProxyHeader(atprotoProxy)
1023
- if (!proxyInfo) {
1024
- return invalidRequestResponse(
1025
- `Invalid atproto-proxy header value: ${atprotoProxy}`,
1026
- )
1027
- }
1028
-
1029
- // @TODO actually implement service proxying logic here. The reason it was
1030
- // not done already is because we want to perform all the heavy lifting
1031
- // here, while still allowing the possibility to override the endpoint
1032
- // resolution, etc.
1033
-
1034
- // @NOTE see ./service-auth.ts for potential common code (did resolver, etc.)
1035
- }
1036
-
1037
- return Response.json(
1038
- {
1039
- error: 'MethodNotImplemented',
1040
- message: `XRPC method "${nsid}" not implemented on this server`,
1041
- },
1042
- { status: 501 },
1043
- )
1044
- }
1045
- }
1046
-
1047
- async function getProcedureInput<M extends Procedure>(
1048
- this: M,
1049
- request: Request,
1050
- ): Promise<InferMethodInput<M, Body>> {
1051
- const encodingRaw = request.headers
1052
- .get('content-type')
1053
- ?.split(';')[0]
1054
- .trim()
1055
- .toLowerCase()
1056
-
1057
- const encoding =
1058
- encodingRaw ||
1059
- // If the caller did not provide a content-type, but the method
1060
- // expects an input, assume binary
1061
- (request.body != null && this.input.encoding != null
1062
- ? 'application/octet-stream'
1063
- : undefined)
1064
-
1065
- if (!this.input.matchesEncoding(encoding)) {
1066
- throw new LexServerError(400, {
1067
- error: 'InvalidRequest',
1068
- message: `Invalid content-type: ${encoding}`,
1069
- })
1070
- }
1071
-
1072
- if (this.input.encoding === 'application/json') {
1073
- // @TODO limit size?
1074
- const data = lexParse(await request.text())
1075
- const body = this.input.schema ? this.input.schema.parse(data) : data
1076
- return { encoding, body } as InferMethodInput<M, Body>
1077
- } else if (this.input.encoding) {
1078
- const body: Body = request
1079
- return { encoding, body } as InferMethodInput<M, Body>
1080
- } else {
1081
- return undefined as InferMethodInput<M, Body>
1082
- }
1083
- }
1084
-
1085
- async function getQueryInput<M extends Query>(
1086
- this: M,
1087
- request: Request,
1088
- ): Promise<InferMethodInput<M, Body>> {
1089
- if (
1090
- request.body ||
1091
- request.headers.has('content-type') ||
1092
- request.headers.has('content-length')
1093
- ) {
1094
- throw new LexServerError(400, {
1095
- error: 'InvalidRequest',
1096
- message: 'GET requests must not have a body',
1097
- })
1098
- }
1099
-
1100
- return undefined as InferMethodInput<M, Body>
1101
- }
1102
-
1103
- function onMessage(this: WebSocket, _event: unknown) {
1104
- const error = 'InvalidRequest'
1105
- const message = 'XRPC subscriptions do not accept messages'
1106
- this.send(encodeErrorFrame({ error, message }))
1107
- // 1003 indicates that an endpoint is terminating the connection
1108
- // because it has received a type of data it cannot accept (e.g., an
1109
- // endpoint that understands only text data MAY send this if it
1110
- // receives a binary message).
1111
- this.close(1003, error)
1112
- }
1113
-
1114
- // Pre-encoded frame header for error frames
1115
- const ERROR_FRAME_HEADER = /*#__PURE__*/ encode({ op: -1 })
1116
-
1117
- function encodeErrorFrame(errorData: LexErrorData): Uint8Array<ArrayBuffer> {
1118
- return ui8Concat([ERROR_FRAME_HEADER, encode(errorData)])
1119
- }
1120
-
1121
- // Pre-encoded frame header for message frames with unknown type
1122
- const UNKNOWN_MESSAGE_FRAME_HEADER = /*#__PURE__*/ encode({ op: 1 })
1123
-
1124
- function encodeMessageFrame(
1125
- method: Subscription,
1126
- value: LexValue,
1127
- ): Uint8Array<ArrayBuffer> {
1128
- if (isPlainObject(value) && typeof value.$type === 'string') {
1129
- const { $type, ...rest } = value
1130
- return ui8Concat([
1131
- encode({
1132
- op: 1,
1133
- t:
1134
- // If $type starts with `nsid#`, strip the NSID prefix
1135
- $type.charCodeAt(0) !== 0x23 && // '#'
1136
- $type.charCodeAt(method.nsid.length) === 0x23 && // '#'
1137
- $type.startsWith(method.nsid)
1138
- ? $type.slice(method.nsid.length)
1139
- : $type,
1140
- }),
1141
- encode(rest),
1142
- ])
1143
- }
1144
-
1145
- return ui8Concat([UNKNOWN_MESSAGE_FRAME_HEADER, encode(value)])
1146
- }
1147
-
1148
- function isAbortReason(signal: AbortSignal, error: unknown): boolean {
1149
- return (
1150
- signal.aborted &&
1151
- signal.reason != null &&
1152
- error instanceof Error &&
1153
- (error === signal.reason || error.cause === signal.reason)
1154
- )
1155
- }
1156
-
1157
- export type ServiceProxyInfo = {
1158
- did: DidString
1159
- serviceId: string
1160
- }
1161
-
1162
- function parseAtprotoProxyHeader(value: string): ServiceProxyInfo | null {
1163
- // /!\ Hot path
1164
-
1165
- // (fast) sanity check to avoid unnecessary parsing for non-DID values
1166
- if (!value.startsWith('did:')) return null
1167
-
1168
- // The format is expected to be `did:example:service#serviceId`
1169
- const hashIndex = value.indexOf('#')
1170
- if (hashIndex === -1) return null
1171
-
1172
- const fragmentIndex = hashIndex + 1
1173
- // Basic validation if the fragment
1174
- if (fragmentIndex === value.length) return null
1175
- if (value.includes('#', fragmentIndex)) return null
1176
- if (value.includes(' ', fragmentIndex)) return null
1177
-
1178
- const did = value.slice(0, hashIndex)
1179
- if (!isDidString(did)) return null
1180
-
1181
- const serviceId = value.slice(fragmentIndex)
1182
- return { did, serviceId }
1183
- }
1184
-
1185
- function normalizeNsid(nsid: NsidString): NsidString {
1186
- const lastDotIdx = nsid.lastIndexOf('.')
1187
-
1188
- // The domain name part of the NSID is case-insensitive, but the last part is
1189
- // case-sensitive. Normalize the domain part to lowercase.
1190
- if (lastDotIdx !== -1 && hasUpperCase(nsid, 0, lastDotIdx)) {
1191
- return `${nsid.slice(0, lastDotIdx).toLowerCase()}.${nsid.slice(lastDotIdx + 1)}` as NsidString
1192
- }
1193
-
1194
- return nsid
1195
- }
1196
-
1197
- function hasUpperCase(str: string, start = 0, end = str.length): boolean {
1198
- for (let i = start; i < end; i++) {
1199
- const code = str.charCodeAt(i)
1200
- if (code >= 0x41 && code <= 0x5a) {
1201
- return true
1202
- }
1203
- }
1204
- return false
1205
- }
1206
-
1207
- function invalidRequestResponse(
1208
- message: string,
1209
- status = 400,
1210
- headers?: HeadersInit,
1211
- ): Response {
1212
- return Response.json(
1213
- {
1214
- error: 'InvalidRequest',
1215
- message,
1216
- },
1217
- { status, headers },
1218
- )
1219
- }