@atproto/lex-server 0.0.8 → 0.0.10

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
@@ -11,6 +11,7 @@ import {
11
11
  import { ListenOptions } from 'node:net'
12
12
  import { Readable } from 'node:stream'
13
13
  import { pipeline } from 'node:stream/promises'
14
+ import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
14
15
  import { createHttpTerminator } from 'http-terminator'
15
16
  import { WebSocket as WebSocketPonyfill, WebSocketServer } from 'ws'
16
17
  import { FetchHandler } from './lex-server.js'
@@ -28,6 +29,36 @@ function isUpgradeRequest(request: Request, upgrade: string): boolean {
28
29
  )
29
30
  }
30
31
 
32
+ /**
33
+ * Upgrades an HTTP request to a WebSocket connection for Node.js.
34
+ *
35
+ * This function must be passed to the {@link LexRouter} constructor to enable
36
+ * subscription (WebSocket) support on Node.js. It creates a WebSocket instance
37
+ * and a placeholder response that signals the need for protocol upgrade.
38
+ *
39
+ * The actual upgrade is handled internally when the response is sent through
40
+ * {@link sendResponse}.
41
+ *
42
+ * @param request - The incoming HTTP request to upgrade
43
+ * @returns An object containing the WebSocket and upgrade response
44
+ * @throws {TypeError} If the request is not a valid WebSocket upgrade request
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * import { LexRouter } from '@atproto/lex-server'
49
+ * import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
50
+ *
51
+ * // Pass to router for subscription support
52
+ * const router = new LexRouter({ upgradeWebSocket })
53
+ *
54
+ * // Now you can add subscription handlers
55
+ * router.add(subscribeRepos, async function* (ctx) {
56
+ * for await (const event of eventStream) {
57
+ * yield event
58
+ * }
59
+ * })
60
+ * ```
61
+ */
31
62
  export function upgradeWebSocket(request: Request): {
32
63
  response: Response
33
64
  socket: WebSocket
@@ -106,6 +137,33 @@ function handleWebSocketUpgrade(
106
137
  })
107
138
  }
108
139
 
140
+ /**
141
+ * Sends a fetch API Response through a Node.js ServerResponse.
142
+ *
143
+ * Handles both regular HTTP responses and WebSocket upgrades. For WebSocket
144
+ * upgrades (status 101), delegates to the WebSocket upgrade handler.
145
+ *
146
+ * This function is used internally by {@link toRequestListener} and
147
+ * {@link createServer}, but can be used directly for custom integrations.
148
+ *
149
+ * @param req - The Node.js IncomingMessage
150
+ * @param res - The Node.js ServerResponse to write to
151
+ * @param response - The fetch API Response to send
152
+ * @throws {TypeError} If headers have already been sent
153
+ *
154
+ * @example
155
+ * ```typescript
156
+ * import http from 'node:http'
157
+ * import { sendResponse } from '@atproto/lex-server/nodejs'
158
+ *
159
+ * const server = http.createServer(async (req, res) => {
160
+ * const response = new Response('Hello, World!', {
161
+ * headers: { 'Content-Type': 'text/plain' }
162
+ * })
163
+ * await sendResponse(req, res, response)
164
+ * })
165
+ * ```
166
+ */
109
167
  export async function sendResponse(
110
168
  req: IncomingMessage,
111
169
  res: ServerResponse,
@@ -128,7 +186,7 @@ export async function sendResponse(
128
186
  }
129
187
 
130
188
  if (response.body != null && req.method !== 'HEAD') {
131
- const stream = Readable.fromWeb(response.body as any)
189
+ const stream = Readable.fromWeb(response.body as NodeReadableStream)
132
190
  await pipeline(stream, res)
133
191
  } else {
134
192
  await response.body?.cancel()
@@ -222,7 +280,7 @@ function toHeaders(headers: IncomingHttpHeaders): Headers {
222
280
  return result
223
281
  }
224
282
 
225
- function toBody(req: IncomingMessage): null | ReadableStream<Uint8Array> {
283
+ function toBody(req: IncomingMessage): null | ReadableStream {
226
284
  if (
227
285
  req.method === 'GET' ||
228
286
  req.method === 'HEAD' ||
@@ -239,21 +297,53 @@ function toBody(req: IncomingMessage): null | ReadableStream<Uint8Array> {
239
297
  return null
240
298
  }
241
299
 
242
- return Readable.toWeb(req) as ReadableStream<Uint8Array>
300
+ return Readable.toWeb(req) as ReadableStream
243
301
  }
244
302
 
303
+ /**
304
+ * Network address type for Node.js TCP connections.
305
+ *
306
+ * @example
307
+ * ```typescript
308
+ * const addr: NetAddr = {
309
+ * transport: 'tcp',
310
+ * hostname: '192.168.1.100',
311
+ * port: 54321
312
+ * }
313
+ * ```
314
+ */
245
315
  export type NetAddr = {
316
+ /** Always 'tcp' for Node.js HTTP connections. */
246
317
  transport: 'tcp'
318
+ /** The IP address of the remote client. */
247
319
  hostname: string
320
+ /** The port number of the remote client. */
248
321
  port: number
249
322
  }
250
323
 
324
+ /**
325
+ * Connection metadata for Node.js HTTP requests.
326
+ *
327
+ * Provides information about the client connection, including the remote
328
+ * address and a promise that resolves when the connection is fully closed
329
+ * (including WebSocket connections).
330
+ */
251
331
  export type NodeConnectionInfo = {
332
+ /** Promise that resolves when the connection is fully closed. */
252
333
  completed: Promise<void>
334
+ /** The remote address of the client, if available. */
253
335
  remoteAddr: NetAddr | undefined
254
336
  }
255
337
 
338
+ /**
339
+ * Interface for objects that can handle fetch-style requests.
340
+ *
341
+ * Used by {@link createServer} and {@link serve} to accept either
342
+ * a fetch handler function or an object with a `fetch` method
343
+ * (like {@link LexRouter}).
344
+ */
256
345
  export interface HandlerObject {
346
+ /** The fetch handler method. */
257
347
  fetch: FetchHandler
258
348
  }
259
349
 
@@ -284,6 +374,32 @@ function toConnectionInfo(req: IncomingMessage): NodeConnectionInfo {
284
374
  }
285
375
  }
286
376
 
377
+ /**
378
+ * Converts a fetch-style handler to a Node.js request listener.
379
+ *
380
+ * The returned listener can be used with Node.js HTTP servers directly,
381
+ * or as middleware in frameworks like Express (supports the `next` callback).
382
+ *
383
+ * @typeParam Request - The request class type (default: IncomingMessage)
384
+ * @typeParam Response - The response class type (default: ServerResponse)
385
+ * @param fetchHandler - The fetch-style handler function
386
+ * @returns A Node.js RequestListener compatible with http.createServer
387
+ *
388
+ * @example Using as Express middleware
389
+ * ```typescript
390
+ * import express from 'express'
391
+ * import { toRequestListener } from '@atproto/lex-server/nodejs'
392
+ * import { LexRouter } from '@atproto/lex-server'
393
+ *
394
+ * const router = new LexRouter()
395
+ * // Register handlers...
396
+ *
397
+ * const app = express()
398
+ *
399
+ * // Mount the XRPC router
400
+ * app.use('/xrpc', toRequestListener(router.fetch))
401
+ * ```
402
+ */
287
403
  export function toRequestListener<
288
404
  Request extends typeof IncomingMessage = typeof IncomingMessage,
289
405
  Response extends typeof ServerResponse<
@@ -310,15 +426,54 @@ export function toRequestListener<
310
426
  }) satisfies RequestListener<Request, Response>
311
427
  }
312
428
 
429
+ /**
430
+ * Options for creating an XRPC server.
431
+ *
432
+ * Extends Node.js {@link ServerOptions} with additional options for graceful shutdown.
433
+ */
313
434
  export type CreateServerOptions<
314
435
  Request extends typeof IncomingMessage = typeof IncomingMessage,
315
436
  Response extends typeof ServerResponse<
316
437
  InstanceType<Request>
317
438
  > = typeof ServerResponse,
318
439
  > = ServerOptions<Request, Response> & {
440
+ /**
441
+ * Timeout in milliseconds for graceful termination.
442
+ *
443
+ * When `terminate()` is called, the server will wait up to this duration
444
+ * for active connections to complete before forcibly closing them.
445
+ */
319
446
  gracefulTerminationTimeout?: number
320
447
  }
321
448
 
449
+ /**
450
+ * Extended HTTP server with graceful shutdown support.
451
+ *
452
+ * Extends the standard Node.js HttpServer with a `terminate()` method
453
+ * for graceful shutdown and implements `AsyncDisposable` for use with
454
+ * `await using`.
455
+ *
456
+ * @typeParam Request - The request class type
457
+ * @typeParam Response - The response class type
458
+ *
459
+ * @example Graceful shutdown
460
+ * ```typescript
461
+ * const server = createServer(router)
462
+ * server.listen(3000)
463
+ *
464
+ * process.on('SIGTERM', async () => {
465
+ * console.log('Shutting down...')
466
+ * await server.terminate()
467
+ * console.log('Server stopped')
468
+ * })
469
+ * ```
470
+ *
471
+ * @example Using with await using
472
+ * ```typescript
473
+ * await using server = await serve(router, { port: 3000 })
474
+ * // Server will be automatically terminated when scope exits
475
+ * ```
476
+ */
322
477
  export interface Server<
323
478
  Request extends typeof IncomingMessage = typeof IncomingMessage,
324
479
  Response extends typeof ServerResponse<
@@ -326,10 +481,56 @@ export interface Server<
326
481
  > = typeof ServerResponse,
327
482
  > extends HttpServer<Request, Response>,
328
483
  AsyncDisposable {
484
+ /**
485
+ * Gracefully terminates the server.
486
+ *
487
+ * Stops accepting new connections and waits for active connections
488
+ * to complete (up to `gracefulTerminationTimeout`).
489
+ *
490
+ * @returns Promise that resolves when the server is fully stopped
491
+ */
329
492
  terminate(): Promise<void>
330
493
  [Symbol.asyncDispose](): Promise<void>
331
494
  }
332
495
 
496
+ /**
497
+ * Creates an HTTP server configured for XRPC request handling.
498
+ *
499
+ * The server includes graceful shutdown support and can be used with
500
+ * either a fetch handler function or an object with a `fetch` method
501
+ * (like {@link LexRouter}).
502
+ *
503
+ * Note: This creates the server but does not start listening. Call
504
+ * `server.listen()` to start the server, or use {@link serve} for
505
+ * a combined create-and-listen operation.
506
+ *
507
+ * @typeParam Request - The request class type
508
+ * @typeParam Response - The response class type
509
+ * @param handler - A fetch handler or object with fetch method
510
+ * @param options - Server configuration options
511
+ * @returns An HTTP server with graceful shutdown support
512
+ *
513
+ * @example Basic usage
514
+ * ```typescript
515
+ * import { LexRouter } from '@atproto/lex-server'
516
+ * import { createServer, upgradeWebSocket } from '@atproto/lex-server/nodejs'
517
+ *
518
+ * const router = new LexRouter({ upgradeWebSocket })
519
+ * router.add(myMethod, myHandler)
520
+ *
521
+ * const server = createServer(router)
522
+ * server.listen(3000, () => {
523
+ * console.log('Server listening on port 3000')
524
+ * })
525
+ * ```
526
+ *
527
+ * @example With graceful termination timeout
528
+ * ```typescript
529
+ * const server = createServer(router, {
530
+ * gracefulTerminationTimeout: 10000 // 10 seconds
531
+ * })
532
+ * ```
533
+ */
333
534
  export function createServer<
334
535
  Request extends typeof IncomingMessage = typeof IncomingMessage,
335
536
  Response extends typeof ServerResponse<
@@ -375,6 +576,23 @@ export function createServer<
375
576
  return server as Server<Request, Response>
376
577
  }
377
578
 
579
+ /**
580
+ * Combined options for creating and starting an XRPC server.
581
+ *
582
+ * Includes both server creation options and network listen options.
583
+ *
584
+ * @typeParam Request - The request class type
585
+ * @typeParam Response - The response class type
586
+ *
587
+ * @example
588
+ * ```typescript
589
+ * const options: StartServerOptions = {
590
+ * port: 3000,
591
+ * host: '0.0.0.0',
592
+ * gracefulTerminationTimeout: 10000
593
+ * }
594
+ * ```
595
+ */
378
596
  export type StartServerOptions<
379
597
  Request extends typeof IncomingMessage = typeof IncomingMessage,
380
598
  Response extends typeof ServerResponse<
@@ -382,6 +600,67 @@ export type StartServerOptions<
382
600
  > = typeof ServerResponse,
383
601
  > = ListenOptions & CreateServerOptions<Request, Response>
384
602
 
603
+ /**
604
+ * Creates and starts an HTTP server, returning when it's ready to accept connections.
605
+ *
606
+ * This is a convenience function that combines {@link createServer} and `server.listen()`
607
+ * into a single async operation. The returned promise resolves once the server
608
+ * is actively listening.
609
+ *
610
+ * @typeParam Request - The request class type
611
+ * @typeParam Response - The response class type
612
+ * @param handler - A fetch handler or object with fetch method (like {@link LexRouter})
613
+ * @param options - Combined server and listen options
614
+ * @returns Promise resolving to the running server
615
+ *
616
+ * @example Basic usage
617
+ * ```typescript
618
+ * import { LexRouter } from '@atproto/lex-server'
619
+ * import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
620
+ *
621
+ * const router = new LexRouter({ upgradeWebSocket })
622
+ *
623
+ * // Register handlers
624
+ * router.add(getProfile, async (ctx) => {
625
+ * return { body: await db.getProfile(ctx.params.actor) }
626
+ * })
627
+ *
628
+ * // Start server on port 3000
629
+ * const server = await serve(router, { port: 3000 })
630
+ * console.log('Server listening on port 3000')
631
+ *
632
+ * // Graceful shutdown
633
+ * process.on('SIGTERM', () => server.terminate())
634
+ * process.on('SIGINT', () => server.terminate())
635
+ * ```
636
+ *
637
+ * @example With all options
638
+ * ```typescript
639
+ * const server = await serve(router, {
640
+ * port: 3000,
641
+ * host: '0.0.0.0',
642
+ * gracefulTerminationTimeout: 15000,
643
+ * })
644
+ * ```
645
+ *
646
+ * @example Using with await using (auto-cleanup)
647
+ * ```typescript
648
+ * async function main() {
649
+ * await using server = await serve(router, { port: 3000 })
650
+ *
651
+ * // Server is running...
652
+ * console.log('Server listening on port 3000')
653
+ *
654
+ * // Wait for termination signal
655
+ * await Promise.race([
656
+ * once(process, 'SIGINT'),
657
+ * once(process, 'SIGTERM'),
658
+ * ])
659
+ *
660
+ * // Server will be automatically terminated when scope exits
661
+ * }
662
+ * ```
663
+ */
385
664
  export async function serve<
386
665
  Request extends typeof IncomingMessage = typeof IncomingMessage,
387
666
  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