@atproto/lex-server 0.0.8 → 0.0.9

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/nodejs.ts CHANGED
@@ -28,6 +28,36 @@ function isUpgradeRequest(request: Request, upgrade: string): boolean {
28
28
  )
29
29
  }
30
30
 
31
+ /**
32
+ * Upgrades an HTTP request to a WebSocket connection for Node.js.
33
+ *
34
+ * This function must be passed to the {@link LexRouter} constructor to enable
35
+ * subscription (WebSocket) support on Node.js. It creates a WebSocket instance
36
+ * and a placeholder response that signals the need for protocol upgrade.
37
+ *
38
+ * The actual upgrade is handled internally when the response is sent through
39
+ * {@link sendResponse}.
40
+ *
41
+ * @param request - The incoming HTTP request to upgrade
42
+ * @returns An object containing the WebSocket and upgrade response
43
+ * @throws {TypeError} If the request is not a valid WebSocket upgrade request
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * import { LexRouter } from '@atproto/lex-server'
48
+ * import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
49
+ *
50
+ * // Pass to router for subscription support
51
+ * const router = new LexRouter({ upgradeWebSocket })
52
+ *
53
+ * // Now you can add subscription handlers
54
+ * router.add(subscribeRepos, async function* (ctx) {
55
+ * for await (const event of eventStream) {
56
+ * yield event
57
+ * }
58
+ * })
59
+ * ```
60
+ */
31
61
  export function upgradeWebSocket(request: Request): {
32
62
  response: Response
33
63
  socket: WebSocket
@@ -106,6 +136,33 @@ function handleWebSocketUpgrade(
106
136
  })
107
137
  }
108
138
 
139
+ /**
140
+ * Sends a fetch API Response through a Node.js ServerResponse.
141
+ *
142
+ * Handles both regular HTTP responses and WebSocket upgrades. For WebSocket
143
+ * upgrades (status 101), delegates to the WebSocket upgrade handler.
144
+ *
145
+ * This function is used internally by {@link toRequestListener} and
146
+ * {@link createServer}, but can be used directly for custom integrations.
147
+ *
148
+ * @param req - The Node.js IncomingMessage
149
+ * @param res - The Node.js ServerResponse to write to
150
+ * @param response - The fetch API Response to send
151
+ * @throws {TypeError} If headers have already been sent
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * import http from 'node:http'
156
+ * import { sendResponse } from '@atproto/lex-server/nodejs'
157
+ *
158
+ * const server = http.createServer(async (req, res) => {
159
+ * const response = new Response('Hello, World!', {
160
+ * headers: { 'Content-Type': 'text/plain' }
161
+ * })
162
+ * await sendResponse(req, res, response)
163
+ * })
164
+ * ```
165
+ */
109
166
  export async function sendResponse(
110
167
  req: IncomingMessage,
111
168
  res: ServerResponse,
@@ -242,18 +299,50 @@ function toBody(req: IncomingMessage): null | ReadableStream<Uint8Array> {
242
299
  return Readable.toWeb(req) as ReadableStream<Uint8Array>
243
300
  }
244
301
 
302
+ /**
303
+ * Network address type for Node.js TCP connections.
304
+ *
305
+ * @example
306
+ * ```typescript
307
+ * const addr: NetAddr = {
308
+ * transport: 'tcp',
309
+ * hostname: '192.168.1.100',
310
+ * port: 54321
311
+ * }
312
+ * ```
313
+ */
245
314
  export type NetAddr = {
315
+ /** Always 'tcp' for Node.js HTTP connections. */
246
316
  transport: 'tcp'
317
+ /** The IP address of the remote client. */
247
318
  hostname: string
319
+ /** The port number of the remote client. */
248
320
  port: number
249
321
  }
250
322
 
323
+ /**
324
+ * Connection metadata for Node.js HTTP requests.
325
+ *
326
+ * Provides information about the client connection, including the remote
327
+ * address and a promise that resolves when the connection is fully closed
328
+ * (including WebSocket connections).
329
+ */
251
330
  export type NodeConnectionInfo = {
331
+ /** Promise that resolves when the connection is fully closed. */
252
332
  completed: Promise<void>
333
+ /** The remote address of the client, if available. */
253
334
  remoteAddr: NetAddr | undefined
254
335
  }
255
336
 
337
+ /**
338
+ * Interface for objects that can handle fetch-style requests.
339
+ *
340
+ * Used by {@link createServer} and {@link serve} to accept either
341
+ * a fetch handler function or an object with a `fetch` method
342
+ * (like {@link LexRouter}).
343
+ */
256
344
  export interface HandlerObject {
345
+ /** The fetch handler method. */
257
346
  fetch: FetchHandler
258
347
  }
259
348
 
@@ -284,6 +373,32 @@ function toConnectionInfo(req: IncomingMessage): NodeConnectionInfo {
284
373
  }
285
374
  }
286
375
 
376
+ /**
377
+ * Converts a fetch-style handler to a Node.js request listener.
378
+ *
379
+ * The returned listener can be used with Node.js HTTP servers directly,
380
+ * or as middleware in frameworks like Express (supports the `next` callback).
381
+ *
382
+ * @typeParam Request - The request class type (default: IncomingMessage)
383
+ * @typeParam Response - The response class type (default: ServerResponse)
384
+ * @param fetchHandler - The fetch-style handler function
385
+ * @returns A Node.js RequestListener compatible with http.createServer
386
+ *
387
+ * @example Using as Express middleware
388
+ * ```typescript
389
+ * import express from 'express'
390
+ * import { toRequestListener } from '@atproto/lex-server/nodejs'
391
+ * import { LexRouter } from '@atproto/lex-server'
392
+ *
393
+ * const router = new LexRouter()
394
+ * // Register handlers...
395
+ *
396
+ * const app = express()
397
+ *
398
+ * // Mount the XRPC router
399
+ * app.use('/xrpc', toRequestListener(router.fetch))
400
+ * ```
401
+ */
287
402
  export function toRequestListener<
288
403
  Request extends typeof IncomingMessage = typeof IncomingMessage,
289
404
  Response extends typeof ServerResponse<
@@ -310,15 +425,54 @@ export function toRequestListener<
310
425
  }) satisfies RequestListener<Request, Response>
311
426
  }
312
427
 
428
+ /**
429
+ * Options for creating an XRPC server.
430
+ *
431
+ * Extends Node.js {@link ServerOptions} with additional options for graceful shutdown.
432
+ */
313
433
  export type CreateServerOptions<
314
434
  Request extends typeof IncomingMessage = typeof IncomingMessage,
315
435
  Response extends typeof ServerResponse<
316
436
  InstanceType<Request>
317
437
  > = typeof ServerResponse,
318
438
  > = ServerOptions<Request, Response> & {
439
+ /**
440
+ * Timeout in milliseconds for graceful termination.
441
+ *
442
+ * When `terminate()` is called, the server will wait up to this duration
443
+ * for active connections to complete before forcibly closing them.
444
+ */
319
445
  gracefulTerminationTimeout?: number
320
446
  }
321
447
 
448
+ /**
449
+ * Extended HTTP server with graceful shutdown support.
450
+ *
451
+ * Extends the standard Node.js HttpServer with a `terminate()` method
452
+ * for graceful shutdown and implements `AsyncDisposable` for use with
453
+ * `await using`.
454
+ *
455
+ * @typeParam Request - The request class type
456
+ * @typeParam Response - The response class type
457
+ *
458
+ * @example Graceful shutdown
459
+ * ```typescript
460
+ * const server = createServer(router)
461
+ * server.listen(3000)
462
+ *
463
+ * process.on('SIGTERM', async () => {
464
+ * console.log('Shutting down...')
465
+ * await server.terminate()
466
+ * console.log('Server stopped')
467
+ * })
468
+ * ```
469
+ *
470
+ * @example Using with await using
471
+ * ```typescript
472
+ * await using server = await serve(router, { port: 3000 })
473
+ * // Server will be automatically terminated when scope exits
474
+ * ```
475
+ */
322
476
  export interface Server<
323
477
  Request extends typeof IncomingMessage = typeof IncomingMessage,
324
478
  Response extends typeof ServerResponse<
@@ -326,10 +480,56 @@ export interface Server<
326
480
  > = typeof ServerResponse,
327
481
  > extends HttpServer<Request, Response>,
328
482
  AsyncDisposable {
483
+ /**
484
+ * Gracefully terminates the server.
485
+ *
486
+ * Stops accepting new connections and waits for active connections
487
+ * to complete (up to `gracefulTerminationTimeout`).
488
+ *
489
+ * @returns Promise that resolves when the server is fully stopped
490
+ */
329
491
  terminate(): Promise<void>
330
492
  [Symbol.asyncDispose](): Promise<void>
331
493
  }
332
494
 
495
+ /**
496
+ * Creates an HTTP server configured for XRPC request handling.
497
+ *
498
+ * The server includes graceful shutdown support and can be used with
499
+ * either a fetch handler function or an object with a `fetch` method
500
+ * (like {@link LexRouter}).
501
+ *
502
+ * Note: This creates the server but does not start listening. Call
503
+ * `server.listen()` to start the server, or use {@link serve} for
504
+ * a combined create-and-listen operation.
505
+ *
506
+ * @typeParam Request - The request class type
507
+ * @typeParam Response - The response class type
508
+ * @param handler - A fetch handler or object with fetch method
509
+ * @param options - Server configuration options
510
+ * @returns An HTTP server with graceful shutdown support
511
+ *
512
+ * @example Basic usage
513
+ * ```typescript
514
+ * import { LexRouter } from '@atproto/lex-server'
515
+ * import { createServer, upgradeWebSocket } from '@atproto/lex-server/nodejs'
516
+ *
517
+ * const router = new LexRouter({ upgradeWebSocket })
518
+ * router.add(myMethod, myHandler)
519
+ *
520
+ * const server = createServer(router)
521
+ * server.listen(3000, () => {
522
+ * console.log('Server listening on port 3000')
523
+ * })
524
+ * ```
525
+ *
526
+ * @example With graceful termination timeout
527
+ * ```typescript
528
+ * const server = createServer(router, {
529
+ * gracefulTerminationTimeout: 10000 // 10 seconds
530
+ * })
531
+ * ```
532
+ */
333
533
  export function createServer<
334
534
  Request extends typeof IncomingMessage = typeof IncomingMessage,
335
535
  Response extends typeof ServerResponse<
@@ -375,6 +575,23 @@ export function createServer<
375
575
  return server as Server<Request, Response>
376
576
  }
377
577
 
578
+ /**
579
+ * Combined options for creating and starting an XRPC server.
580
+ *
581
+ * Includes both server creation options and network listen options.
582
+ *
583
+ * @typeParam Request - The request class type
584
+ * @typeParam Response - The response class type
585
+ *
586
+ * @example
587
+ * ```typescript
588
+ * const options: StartServerOptions = {
589
+ * port: 3000,
590
+ * host: '0.0.0.0',
591
+ * gracefulTerminationTimeout: 10000
592
+ * }
593
+ * ```
594
+ */
378
595
  export type StartServerOptions<
379
596
  Request extends typeof IncomingMessage = typeof IncomingMessage,
380
597
  Response extends typeof ServerResponse<
@@ -382,6 +599,67 @@ export type StartServerOptions<
382
599
  > = typeof ServerResponse,
383
600
  > = ListenOptions & CreateServerOptions<Request, Response>
384
601
 
602
+ /**
603
+ * Creates and starts an HTTP server, returning when it's ready to accept connections.
604
+ *
605
+ * This is a convenience function that combines {@link createServer} and `server.listen()`
606
+ * into a single async operation. The returned promise resolves once the server
607
+ * is actively listening.
608
+ *
609
+ * @typeParam Request - The request class type
610
+ * @typeParam Response - The response class type
611
+ * @param handler - A fetch handler or object with fetch method (like {@link LexRouter})
612
+ * @param options - Combined server and listen options
613
+ * @returns Promise resolving to the running server
614
+ *
615
+ * @example Basic usage
616
+ * ```typescript
617
+ * import { LexRouter } from '@atproto/lex-server'
618
+ * import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
619
+ *
620
+ * const router = new LexRouter({ upgradeWebSocket })
621
+ *
622
+ * // Register handlers
623
+ * router.add(getProfile, async (ctx) => {
624
+ * return { body: await db.getProfile(ctx.params.actor) }
625
+ * })
626
+ *
627
+ * // Start server on port 3000
628
+ * const server = await serve(router, { port: 3000 })
629
+ * console.log('Server listening on port 3000')
630
+ *
631
+ * // Graceful shutdown
632
+ * process.on('SIGTERM', () => server.terminate())
633
+ * process.on('SIGINT', () => server.terminate())
634
+ * ```
635
+ *
636
+ * @example With all options
637
+ * ```typescript
638
+ * const server = await serve(router, {
639
+ * port: 3000,
640
+ * host: '0.0.0.0',
641
+ * gracefulTerminationTimeout: 15000,
642
+ * })
643
+ * ```
644
+ *
645
+ * @example Using with await using (auto-cleanup)
646
+ * ```typescript
647
+ * async function main() {
648
+ * await using server = await serve(router, { port: 3000 })
649
+ *
650
+ * // Server is running...
651
+ * console.log('Server listening on port 3000')
652
+ *
653
+ * // Wait for termination signal
654
+ * await Promise.race([
655
+ * once(process, 'SIGINT'),
656
+ * once(process, 'SIGTERM'),
657
+ * ])
658
+ *
659
+ * // Server will be automatically terminated when scope exits
660
+ * }
661
+ * ```
662
+ */
385
663
  export async function serve<
386
664
  Request extends typeof IncomingMessage = typeof IncomingMessage,
387
665
  Response extends typeof ServerResponse<
@@ -17,41 +17,150 @@ import { LexRouterAuth } from './lex-server.js'
17
17
  const BEARER_PREFIX = 'Bearer '
18
18
 
19
19
  /**
20
- * A function to check and record nonce uniqueness.
20
+ * Callback function to check and record nonce uniqueness.
21
+ *
22
+ * Used to prevent replay attacks by ensuring each nonce is only used once.
23
+ * The implementation must track nonces for at least the `maxAge` duration
24
+ * (default 5 minutes before and after the current time).
25
+ *
26
+ * @param nonce - The nonce string from the JWT token
27
+ * @returns Promise resolving to `true` if the nonce is unique (first time seen),
28
+ * `false` if it has been seen before
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * // Using Redis for nonce tracking
33
+ * const checkNonce: UniqueNonceChecker = async (nonce) => {
34
+ * const key = `nonce:${nonce}`
35
+ * const result = await redis.setnx(key, '1')
36
+ * if (result === 1) {
37
+ * await redis.expire(key, 600) // 10 minutes TTL
38
+ * return true
39
+ * }
40
+ * return false
41
+ * }
42
+ * ```
21
43
  */
22
44
  export type UniqueNonceChecker = (nonce: string) => Promise<boolean>
23
45
 
46
+ /**
47
+ * Configuration options for AT Protocol service authentication.
48
+ *
49
+ * Service auth is used for server-to-server communication in the AT Protocol,
50
+ * where one service authenticates to another using signed JWT tokens tied to
51
+ * the caller's DID.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const options: ServiceAuthOptions = {
56
+ * audience: 'did:web:api.example.com',
57
+ * unique: async (nonce) => nonceStore.checkAndAdd(nonce),
58
+ * maxAge: 300, // 5 minutes
59
+ * // Optional DID resolver options
60
+ * plcDirectoryUrl: 'https://plc.directory'
61
+ * }
62
+ * ```
63
+ */
24
64
  export type ServiceAuthOptions = CreateDidResolverOptions & {
25
65
  /**
26
- * Expected audience ("aud") claim in the JWT token. Set to `null` to skip
27
- * audience verification (not recommended).
66
+ * Expected audience ("aud") claim in the JWT token.
67
+ *
68
+ * This should be the DID of your service. The token must include this
69
+ * value in its `aud` claim to be accepted. Set to `null` to skip
70
+ * audience verification (not recommended for production).
28
71
  */
29
72
  audience: null | DidString
30
73
  /**
31
- * Function to check and record nonce uniqueness. The value checked here must
32
- * be unique within {@link ServiceAuthOptions.maxAge} seconds before and after
33
- * the current time.
74
+ * Function to check and record nonce uniqueness.
34
75
  *
35
- * @param nonce - The nonce to check.
76
+ * This is critical for preventing replay attacks. The value checked here
77
+ * must be unique within `maxAge` seconds before and after the current time.
78
+ *
79
+ * @param nonce - The nonce to check
80
+ * @returns Promise resolving to `true` if unique, `false` if seen before
36
81
  */
37
82
  unique: UniqueNonceChecker
38
83
  /**
39
84
  * Maximum age of the JWT token in seconds.
40
85
  *
86
+ * Tokens with `iat` (issued at) or `exp` (expiry) timestamps outside
87
+ * this window from the current time will be rejected.
88
+ *
41
89
  * @default 300 (5 minutes)
42
90
  */
43
91
  maxAge?: number
44
92
  }
45
93
 
94
+ /**
95
+ * Credentials returned after successful service authentication.
96
+ *
97
+ * Contains the verified DID, resolved DID document, and parsed JWT token.
98
+ * These are available in handler context as `ctx.credentials`.
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * router.add(protectedMethod, {
103
+ * handler: async (ctx) => {
104
+ * const { did, didDocument, jwt } = ctx.credentials
105
+ * console.log('Request from:', did)
106
+ * console.log('Token expires:', new Date(jwt.payload.exp * 1000))
107
+ * return { body: { callerDid: did } }
108
+ * },
109
+ * auth: serviceAuth({ audience: myDid, unique: checkNonce })
110
+ * })
111
+ * ```
112
+ */
46
113
  export type ServiceAuthCredentials = {
114
+ /** The verified AT Protocol DID of the caller. */
47
115
  did: AtprotoDid
116
+ /** The resolved DID document of the caller. */
48
117
  didDocument: AtprotoDidDocument
118
+ /** The parsed and validated JWT token. */
49
119
  jwt: ParsedJwt
50
120
  }
51
121
 
52
122
  /**
53
- * Creates an authentication handler for LexRouter that verifies AT protocol
54
- * "service auth" JWT bearer tokens signed by decentralized identifiers (DIDs).
123
+ * Creates an authentication handler for verifying AT Protocol service auth JWTs.
124
+ *
125
+ * Service auth is the standard authentication mechanism for server-to-server
126
+ * communication in the AT Protocol. It uses JWT bearer tokens signed by the
127
+ * caller's DID signing key, with the signature verified against the public
128
+ * key in the caller's DID document.
129
+ *
130
+ * The handler performs the following validations:
131
+ * - Extracts and parses the Bearer token from the Authorization header
132
+ * - Validates JWT structure and claims (aud, exp, iat, lxm, nonce)
133
+ * - Resolves the issuer's DID document
134
+ * - Verifies the JWT signature against the `#atproto` verification method
135
+ * - Checks nonce uniqueness to prevent replay attacks
136
+ *
137
+ * @param options - Configuration options for service auth
138
+ * @returns An auth handler function for use with {@link LexRouter.add}
139
+ *
140
+ * @example Basic usage
141
+ * ```typescript
142
+ * import { LexRouter, serviceAuth } from '@atproto/lex-server'
143
+ *
144
+ * const router = new LexRouter()
145
+ *
146
+ * const auth = serviceAuth({
147
+ * audience: 'did:web:api.example.com',
148
+ * unique: async (nonce) => {
149
+ * // Check if nonce has been seen, return true if unique
150
+ * const isNew = await redis.setnx(`nonce:${nonce}`, '1')
151
+ * if (isNew) await redis.expire(`nonce:${nonce}`, 600)
152
+ * return isNew
153
+ * }
154
+ * })
155
+ *
156
+ * router.add(myMethod, {
157
+ * handler: async (ctx) => {
158
+ * console.log('Authenticated as:', ctx.credentials.did)
159
+ * return { body: { success: true } }
160
+ * },
161
+ * auth
162
+ * })
163
+ * ```
55
164
  */
56
165
  export function serviceAuth({
57
166
  audience,
@@ -190,17 +299,50 @@ async function parseJwtBearer(
190
299
  return parseJwt(token, options)
191
300
  }
192
301
 
302
+ /**
303
+ * Options for parsing and validating a JWT token.
304
+ */
193
305
  export type ParseJwtOptions = {
306
+ /** Maximum age in seconds for token validity window. */
194
307
  maxAge: number
308
+ /** Expected audience claim, or null to skip audience verification. */
195
309
  audience: null | DidString
310
+ /** Function to check nonce uniqueness. */
196
311
  unique: UniqueNonceChecker
312
+ /** Expected lexicon method NSID for the `lxm` claim. */
197
313
  lxm: string
198
314
  }
199
315
 
316
+ /**
317
+ * A parsed and partially validated JWT token.
318
+ *
319
+ * Contains the decoded header and payload, along with the raw bytes
320
+ * needed for signature verification.
321
+ *
322
+ * @example
323
+ * ```typescript
324
+ * const jwt: ParsedJwt = {
325
+ * header: { alg: 'ES256K', typ: 'JWT' },
326
+ * payload: {
327
+ * iss: 'did:plc:abc123',
328
+ * aud: 'did:web:api.example.com',
329
+ * exp: 1704067200,
330
+ * iat: 1704066900,
331
+ * lxm: 'com.atproto.sync.getBlob'
332
+ * },
333
+ * message: new Uint8Array([...]),
334
+ * signature: new Uint8Array([...])
335
+ * }
336
+ * ```
337
+ */
200
338
  export type ParsedJwt = {
339
+ /** The decoded JWT header containing algorithm and type. */
201
340
  header: HeaderObject
341
+ /** The decoded JWT payload containing claims. */
202
342
  payload: PayloadObject
343
+ /** The raw header.payload bytes for signature verification. */
203
344
  message: Uint8Array
345
+ /** The decoded signature bytes. */
204
346
  signature: Uint8Array
205
347
  }
206
348