@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/nodejs.ts DELETED
@@ -1,678 +0,0 @@
1
- import { once } from 'node:events'
2
- import {
3
- IncomingHttpHeaders,
4
- IncomingMessage,
5
- RequestListener,
6
- Server as HttpServer,
7
- ServerOptions,
8
- ServerResponse,
9
- createServer as createHttpServer,
10
- } from 'node:http'
11
- import { ListenOptions } from 'node:net'
12
- import { Readable } from 'node:stream'
13
- import { pipeline } from 'node:stream/promises'
14
- import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
15
- // eslint-disable-next-line import/default
16
- import httpTerminator from 'http-terminator'
17
- import { WebSocket as WebSocketPonyfill, WebSocketServer } from 'ws'
18
- import type { FetchHandler } from './lex-router.js'
19
-
20
- // @ts-expect-error
21
- Symbol.asyncDispose ??= Symbol.for('Symbol.asyncDispose')
22
-
23
- const kResponseWs = Symbol.for('@atproto/lex-server:WebSocket')
24
-
25
- function isUpgradeRequest(request: Request, upgrade: string): boolean {
26
- return (
27
- request.method === 'GET' &&
28
- request.headers.get('connection')?.toLowerCase() === 'upgrade' &&
29
- request.headers.get('upgrade')?.toLowerCase() === upgrade
30
- )
31
- }
32
-
33
- /**
34
- * Upgrades an HTTP request to a WebSocket connection for Node.js.
35
- *
36
- * This function must be passed to the {@link LexRouter} constructor to enable
37
- * subscription (WebSocket) support on Node.js. It creates a WebSocket instance
38
- * and a placeholder response that signals the need for protocol upgrade.
39
- *
40
- * The actual upgrade is handled internally when the response is sent through
41
- * {@link sendResponse}.
42
- *
43
- * @param request - The incoming HTTP request to upgrade
44
- * @returns An object containing the WebSocket and upgrade response
45
- * @throws {TypeError} If the request is not a valid WebSocket upgrade request
46
- *
47
- * @example
48
- * ```typescript
49
- * import { LexRouter } from '@atproto/lex-server'
50
- * import { upgradeWebSocket } from '@atproto/lex-server/nodejs'
51
- *
52
- * // Pass to router for subscription support
53
- * const router = new LexRouter({ upgradeWebSocket })
54
- *
55
- * // Now you can add subscription handlers
56
- * router.add(subscribeRepos, async function* (ctx) {
57
- * for await (const event of eventStream) {
58
- * yield event
59
- * }
60
- * })
61
- * ```
62
- */
63
- export function upgradeWebSocket(request: Request): {
64
- response: Response
65
- socket: WebSocket
66
- } {
67
- if (!isUpgradeRequest(request, 'websocket')) {
68
- throw new TypeError('upgradeWebSocket() expects a WebSocket upgrade')
69
- }
70
-
71
- // Placeholder response for WebSocket upgrade. The actual handling will happen
72
- // through the handleWebSocketUpgrade function. Headers set on the response
73
- // will be applied during the upgrade.
74
- const response = new Response(null, { status: 200 })
75
-
76
- // The Response constructor does not allow setting status 101, so we
77
- // define it directly. The purpose of this response is just to signal
78
- // that an upgrade is needed, and to carry any headers.
79
- Object.defineProperty(response, 'status', {
80
- value: 101,
81
- enumerable: false,
82
- configurable: false,
83
- writable: false,
84
- })
85
-
86
- // @ts-expect-error
87
- const socket: WebSocket = new WebSocketPonyfill(null, undefined, {
88
- autoPong: true,
89
- })
90
-
91
- // Attach the WebSocket to the response for later retrieval
92
- Object.defineProperty(response, kResponseWs, {
93
- value: socket,
94
- enumerable: false,
95
- configurable: false,
96
- writable: false,
97
- })
98
-
99
- return { response, socket }
100
- }
101
-
102
- const kUpgradeEvent = Symbol.for('@atproto/lex-server:upgrade')
103
-
104
- function handleWebSocketUpgrade(
105
- req: IncomingMessage,
106
- response: Response,
107
- ): void {
108
- const ws = (response as { [kResponseWs]?: WebSocketPonyfill })[kResponseWs]
109
- if (!ws) throw new TypeError('Response not created by upgradeWebSocket()')
110
-
111
- // Create a one time use WebSocketServer to handle the upgrade
112
- const wss = new WebSocketServer({
113
- autoPong: true,
114
- noServer: true,
115
- clientTracking: false,
116
- perMessageDeflate: true,
117
- // @ts-expect-error
118
- WebSocket: function () {
119
- // Return the websocket that was created earlier instead of a new instance
120
- return ws
121
- },
122
- })
123
-
124
- // Apply headers that might have been set on the response object during
125
- // handling. This will be called during wss.handleUpgrade().
126
- wss.on('headers', (headers) => {
127
- for (const [name, value] of response.headers) {
128
- headers.push(`${name}: ${value}`)
129
- }
130
- })
131
-
132
- wss.handleUpgrade(req, req.socket, Buffer.alloc(0), (_socket) => {
133
- // @TODO find a way to properly "close" the _socket when the server is
134
- // shutting down (might require replacing http-terminator with a local
135
- // implementation)
136
-
137
- req.emit(kUpgradeEvent, ws)
138
- })
139
- }
140
-
141
- /**
142
- * Sends a fetch API Response through a Node.js ServerResponse.
143
- *
144
- * Handles both regular HTTP responses and WebSocket upgrades. For WebSocket
145
- * upgrades (status 101), delegates to the WebSocket upgrade handler.
146
- *
147
- * This function is used internally by {@link toRequestListener} and
148
- * {@link createServer}, but can be used directly for custom integrations.
149
- *
150
- * @param req - The Node.js IncomingMessage
151
- * @param res - The Node.js ServerResponse to write to
152
- * @param response - The fetch API Response to send
153
- * @throws {TypeError} If headers have already been sent
154
- *
155
- * @example
156
- * ```typescript
157
- * import http from 'node:http'
158
- * import { sendResponse } from '@atproto/lex-server/nodejs'
159
- *
160
- * const server = http.createServer(async (req, res) => {
161
- * const response = new Response('Hello, World!', {
162
- * headers: { 'Content-Type': 'text/plain' }
163
- * })
164
- * await sendResponse(req, res, response)
165
- * })
166
- * ```
167
- */
168
- export async function sendResponse(
169
- req: IncomingMessage,
170
- res: ServerResponse,
171
- response: Response,
172
- ): Promise<void> {
173
- // Invalid usage
174
- if (res.headersSent) {
175
- throw new TypeError('Response has already been sent')
176
- }
177
-
178
- if (response.status === 101) {
179
- return handleWebSocketUpgrade(req, response)
180
- }
181
-
182
- res.statusCode = response.status
183
- res.statusMessage = response.statusText
184
-
185
- for (const [key, value] of response.headers) {
186
- res.setHeader(key, value)
187
- }
188
-
189
- if (response.body != null && req.method !== 'HEAD') {
190
- const stream = Readable.fromWeb(response.body as NodeReadableStream)
191
- await pipeline(stream, res)
192
- } else {
193
- await response.body?.cancel()
194
- res.end()
195
- }
196
- }
197
-
198
- function toRequest(req: IncomingMessage): Request {
199
- const host = req.headers.host ?? req.socket.localAddress ?? 'localhost'
200
- const isEncrypted = (req.socket as any).encrypted === true
201
- const protocol = isEncrypted ? 'https' : 'http'
202
- const url = new URL(req.url ?? '/', `${protocol}://${host}`)
203
- const headers = toHeaders(req.headers)
204
- const body = toBody(req)
205
- const signal = requestSignal(req)
206
-
207
- return new Request(url, {
208
- signal,
209
- method: req.method,
210
- headers,
211
- body,
212
- referrer: headers.get('referrer') ?? headers.get('referer') ?? undefined,
213
- redirect: 'manual',
214
- // @ts-expect-error
215
- duplex: body ? 'half' : undefined,
216
- })
217
- }
218
-
219
- function requestSignal(req: IncomingMessage): AbortSignal {
220
- if (req.destroyed) return AbortSignal.abort()
221
-
222
- const abortController = new AbortController()
223
-
224
- const abort = (err?: Error | WebSocket) => {
225
- abortController.abort(err instanceof Error ? err : undefined)
226
-
227
- req.off('close', abort)
228
- req.off('error', abort)
229
- req.off('end', abort)
230
- req.off(kUpgradeEvent, abort)
231
- }
232
-
233
- req.on('close', abort)
234
- req.on('error', abort)
235
- req.on('end', abort)
236
- req.on(kUpgradeEvent, abort)
237
-
238
- return abortController.signal
239
- }
240
-
241
- function requestCompletion(req: IncomingMessage): Promise<void> {
242
- if (req.destroyed) return Promise.resolve()
243
-
244
- // Unlike the abort signal, we complete the promise only when the request
245
- // is fully done, accounting for websocket upgrade.
246
- return new Promise((resolve) => {
247
- const cleanup = () => {
248
- req.off('close', done)
249
- req.off('error', done)
250
- req.off('end', done)
251
- req.off(kUpgradeEvent, onUpgrade)
252
- }
253
-
254
- const onUpgrade = (ws: WebSocket) => {
255
- cleanup()
256
- ws.addEventListener('close', () => resolve())
257
- }
258
-
259
- const done = () => {
260
- resolve()
261
- cleanup()
262
- }
263
-
264
- req.on('close', done)
265
- req.on('error', done)
266
- req.on('end', done)
267
- req.on(kUpgradeEvent, onUpgrade)
268
- })
269
- }
270
-
271
- function toHeaders(headers: IncomingHttpHeaders): Headers {
272
- const result = new Headers()
273
- for (const [key, value] of Object.entries(headers)) {
274
- if (value === undefined) continue
275
- if (Array.isArray(value)) {
276
- for (const v of value) result.append(key, v)
277
- } else {
278
- result.set(key, value)
279
- }
280
- }
281
- return result
282
- }
283
-
284
- function toBody(req: IncomingMessage): null | ReadableStream {
285
- if (
286
- req.method === 'GET' ||
287
- req.method === 'HEAD' ||
288
- req.method === 'OPTIONS'
289
- ) {
290
- return null
291
- }
292
-
293
- if (
294
- req.headers['content-type'] == null &&
295
- req.headers['transfer-encoding'] == null &&
296
- req.headers['content-length'] == null
297
- ) {
298
- return null
299
- }
300
-
301
- return Readable.toWeb(req) as ReadableStream
302
- }
303
-
304
- /**
305
- * Network address type for Node.js TCP connections.
306
- *
307
- * @example
308
- * ```typescript
309
- * const addr: NetAddr = {
310
- * transport: 'tcp',
311
- * hostname: '192.168.1.100',
312
- * port: 54321
313
- * }
314
- * ```
315
- */
316
- export type NetAddr = {
317
- /** Always 'tcp' for Node.js HTTP connections. */
318
- transport: 'tcp'
319
- /** The IP address of the remote client. */
320
- hostname: string
321
- /** The port number of the remote client. */
322
- port: number
323
- }
324
-
325
- /**
326
- * Connection metadata for Node.js HTTP requests.
327
- *
328
- * Provides information about the client connection, including the remote
329
- * address and a promise that resolves when the connection is fully closed
330
- * (including WebSocket connections).
331
- */
332
- export type NodeConnectionInfo = {
333
- /** Promise that resolves when the connection is fully closed. */
334
- completed: Promise<void>
335
- /** The remote address of the client, if available. */
336
- remoteAddr: NetAddr | undefined
337
- }
338
-
339
- /**
340
- * Interface for objects that can handle fetch-style requests.
341
- *
342
- * Used by {@link createServer} and {@link serve} to accept either
343
- * a fetch handler function or an object with a `fetch` method
344
- * (like {@link LexRouter}).
345
- */
346
- export interface HandlerObject {
347
- /** The fetch handler method. */
348
- fetch: FetchHandler
349
- }
350
-
351
- async function handleRequest(
352
- req: IncomingMessage,
353
- res: ServerResponse,
354
- fetchHandler: FetchHandler,
355
- ) {
356
- const request = toRequest(req)
357
- const info = toConnectionInfo(req)
358
- const response = await fetchHandler(request, info)
359
- await sendResponse(req, res, response)
360
- }
361
-
362
- function toConnectionInfo(req: IncomingMessage): NodeConnectionInfo {
363
- const { socket } = req
364
-
365
- return {
366
- completed: requestCompletion(req),
367
- remoteAddr:
368
- socket.remoteAddress != null
369
- ? {
370
- transport: 'tcp',
371
- hostname: socket.remoteAddress,
372
- port: socket.remotePort!,
373
- }
374
- : undefined,
375
- }
376
- }
377
-
378
- /**
379
- * Converts a fetch-style handler to a Node.js request listener.
380
- *
381
- * The returned listener can be used with Node.js HTTP servers directly,
382
- * or as middleware in frameworks like Express (supports the `next` callback).
383
- *
384
- * @typeParam Request - The request class type (default: IncomingMessage)
385
- * @typeParam Response - The response class type (default: ServerResponse)
386
- * @param fetchHandler - The fetch-style handler function
387
- * @returns A Node.js RequestListener compatible with http.createServer
388
- *
389
- * @example Using as Express middleware
390
- * ```typescript
391
- * import express from 'express'
392
- * import { toRequestListener } from '@atproto/lex-server/nodejs'
393
- * import { LexRouter } from '@atproto/lex-server'
394
- *
395
- * const router = new LexRouter()
396
- * // Register handlers...
397
- *
398
- * const app = express()
399
- *
400
- * // Mount the XRPC router
401
- * app.use(toRequestListener(router.fetch))
402
- * ```
403
- */
404
- export function toRequestListener<
405
- Request extends typeof IncomingMessage = typeof IncomingMessage,
406
- Response extends typeof ServerResponse<
407
- InstanceType<Request>
408
- > = typeof ServerResponse,
409
- >(fetchHandler: FetchHandler) {
410
- return ((
411
- req: InstanceType<Request>,
412
- res: InstanceType<Response> & { req: InstanceType<Request> },
413
- next?: (err?: unknown) => void,
414
- ): void => {
415
- handleRequest(req, res, fetchHandler).catch((err) => {
416
- if (next) next(err)
417
- else {
418
- if (!res.headersSent) {
419
- res.statusCode = 500
420
- res.setHeader('content-type', 'text/plain; charset=utf-8')
421
- res.end('Internal Server Error')
422
- } else if (!res.writableEnded) {
423
- res.destroy()
424
- }
425
- }
426
- })
427
- }) satisfies RequestListener<Request, Response>
428
- }
429
-
430
- /**
431
- * Options for creating an XRPC server.
432
- *
433
- * Extends Node.js {@link ServerOptions} with additional options for graceful shutdown.
434
- */
435
- export type CreateServerOptions<
436
- Request extends typeof IncomingMessage = typeof IncomingMessage,
437
- Response extends typeof ServerResponse<
438
- InstanceType<Request>
439
- > = typeof ServerResponse,
440
- > = ServerOptions<Request, Response> & {
441
- /**
442
- * Timeout in milliseconds for graceful termination.
443
- *
444
- * When `terminate()` is called, the server will wait up to this duration
445
- * for active connections to complete before forcibly closing them.
446
- */
447
- gracefulTerminationTimeout?: number
448
- }
449
-
450
- /**
451
- * Extended HTTP server with graceful shutdown support.
452
- *
453
- * Extends the standard Node.js HttpServer with a `terminate()` method
454
- * for graceful shutdown and implements `AsyncDisposable` for use with
455
- * `await using`.
456
- *
457
- * @typeParam Request - The request class type
458
- * @typeParam Response - The response class type
459
- *
460
- * @example Graceful shutdown
461
- * ```typescript
462
- * const server = createServer(router)
463
- * server.listen(3000)
464
- *
465
- * process.on('SIGTERM', async () => {
466
- * console.log('Shutting down...')
467
- * await server.terminate()
468
- * console.log('Server stopped')
469
- * })
470
- * ```
471
- *
472
- * @example Using with await using
473
- * ```typescript
474
- * await using server = await serve(router, { port: 3000 })
475
- * // Server will be automatically terminated when scope exits
476
- * ```
477
- */
478
- export interface Server<
479
- Request extends typeof IncomingMessage = typeof IncomingMessage,
480
- Response extends typeof ServerResponse<
481
- InstanceType<Request>
482
- > = typeof ServerResponse,
483
- > extends HttpServer<Request, Response>,
484
- AsyncDisposable {
485
- /**
486
- * Gracefully terminates the server.
487
- *
488
- * Stops accepting new connections and waits for active connections
489
- * to complete (up to `gracefulTerminationTimeout`).
490
- *
491
- * @returns Promise that resolves when the server is fully stopped
492
- */
493
- terminate(): Promise<void>
494
- [Symbol.asyncDispose](): Promise<void>
495
- }
496
-
497
- /**
498
- * Creates an HTTP server configured for XRPC request handling.
499
- *
500
- * The server includes graceful shutdown support and can be used with
501
- * either a fetch handler function or an object with a `fetch` method
502
- * (like {@link LexRouter}).
503
- *
504
- * Note: This creates the server but does not start listening. Call
505
- * `server.listen()` to start the server, or use {@link serve} for
506
- * a combined create-and-listen operation.
507
- *
508
- * @typeParam Request - The request class type
509
- * @typeParam Response - The response class type
510
- * @param handler - A fetch handler or object with fetch method
511
- * @param options - Server configuration options
512
- * @returns An HTTP server with graceful shutdown support
513
- *
514
- * @example Basic usage
515
- * ```typescript
516
- * import { LexRouter } from '@atproto/lex-server'
517
- * import { createServer, upgradeWebSocket } from '@atproto/lex-server/nodejs'
518
- *
519
- * const router = new LexRouter({ upgradeWebSocket })
520
- * router.add(myMethod, myHandler)
521
- *
522
- * const server = createServer(router)
523
- * server.listen(3000, () => {
524
- * console.log('Server listening on port 3000')
525
- * })
526
- * ```
527
- *
528
- * @example With graceful termination timeout
529
- * ```typescript
530
- * const server = createServer(router, {
531
- * gracefulTerminationTimeout: 10000 // 10 seconds
532
- * })
533
- * ```
534
- */
535
- export function createServer<
536
- Request extends typeof IncomingMessage = typeof IncomingMessage,
537
- Response extends typeof ServerResponse<
538
- InstanceType<Request>
539
- > = typeof ServerResponse,
540
- >(
541
- handler: FetchHandler | HandlerObject,
542
- options: CreateServerOptions<Request, Response> = {},
543
- ): Server<Request, Response> {
544
- const fetchHandler =
545
- typeof handler === 'function' ? handler : handler.fetch.bind(handler)
546
-
547
- const listener = toRequestListener(fetchHandler)
548
- const server = createHttpServer(options, listener)
549
-
550
- const terminator = httpTerminator.createHttpTerminator({
551
- server: server as HttpServer,
552
- gracefulTerminationTimeout: options?.gracefulTerminationTimeout,
553
- })
554
-
555
- const terminate = async function terminate(this: Server<Request, Response>) {
556
- if (this !== server) {
557
- throw new TypeError('Server.terminate called with incorrect context')
558
- }
559
- // @TODO properly close all active WebSocket connections
560
- return terminator.terminate()
561
- }
562
-
563
- Object.defineProperty(server, 'terminate', {
564
- value: terminate,
565
- enumerable: false,
566
- configurable: false,
567
- writable: false,
568
- })
569
-
570
- Object.defineProperty(server, Symbol.asyncDispose, {
571
- value: terminate,
572
- enumerable: false,
573
- configurable: false,
574
- writable: false,
575
- })
576
-
577
- return server as Server<Request, Response>
578
- }
579
-
580
- /**
581
- * Combined options for creating and starting an XRPC server.
582
- *
583
- * Includes both server creation options and network listen options.
584
- *
585
- * @typeParam Request - The request class type
586
- * @typeParam Response - The response class type
587
- *
588
- * @example
589
- * ```typescript
590
- * const options: StartServerOptions = {
591
- * port: 3000,
592
- * host: '0.0.0.0',
593
- * gracefulTerminationTimeout: 10000
594
- * }
595
- * ```
596
- */
597
- export type StartServerOptions<
598
- Request extends typeof IncomingMessage = typeof IncomingMessage,
599
- Response extends typeof ServerResponse<
600
- InstanceType<Request>
601
- > = typeof ServerResponse,
602
- > = ListenOptions & CreateServerOptions<Request, Response>
603
-
604
- /**
605
- * Creates and starts an HTTP server, returning when it's ready to accept connections.
606
- *
607
- * This is a convenience function that combines {@link createServer} and `server.listen()`
608
- * into a single async operation. The returned promise resolves once the server
609
- * is actively listening.
610
- *
611
- * @typeParam Request - The request class type
612
- * @typeParam Response - The response class type
613
- * @param handler - A fetch handler or object with fetch method (like {@link LexRouter})
614
- * @param options - Combined server and listen options
615
- * @returns Promise resolving to the running server
616
- *
617
- * @example Basic usage
618
- * ```typescript
619
- * import { LexRouter } from '@atproto/lex-server'
620
- * import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
621
- *
622
- * const router = new LexRouter({ upgradeWebSocket })
623
- *
624
- * // Register handlers
625
- * router.add(getProfile, async (ctx) => {
626
- * return { body: await db.getProfile(ctx.params.actor) }
627
- * })
628
- *
629
- * // Start server on port 3000
630
- * const server = await serve(router, { port: 3000 })
631
- * console.log('Server listening on port 3000')
632
- *
633
- * // Graceful shutdown
634
- * process.on('SIGTERM', () => server.terminate())
635
- * process.on('SIGINT', () => server.terminate())
636
- * ```
637
- *
638
- * @example With all options
639
- * ```typescript
640
- * const server = await serve(router, {
641
- * port: 3000,
642
- * host: '0.0.0.0',
643
- * gracefulTerminationTimeout: 15000,
644
- * })
645
- * ```
646
- *
647
- * @example Using with await using (auto-cleanup)
648
- * ```typescript
649
- * async function main() {
650
- * await using server = await serve(router, { port: 3000 })
651
- *
652
- * // Server is running...
653
- * console.log('Server listening on port 3000')
654
- *
655
- * // Wait for termination signal
656
- * await Promise.race([
657
- * once(process, 'SIGINT'),
658
- * once(process, 'SIGTERM'),
659
- * ])
660
- *
661
- * // Server will be automatically terminated when scope exits
662
- * }
663
- * ```
664
- */
665
- export async function serve<
666
- Request extends typeof IncomingMessage = typeof IncomingMessage,
667
- Response extends typeof ServerResponse<
668
- InstanceType<Request>
669
- > = typeof ServerResponse,
670
- >(
671
- handler: FetchHandler | HandlerObject,
672
- options?: StartServerOptions<Request, Response>,
673
- ): Promise<Server<Request, Response>> {
674
- const server = createServer(handler, options)
675
- server.listen(options)
676
- await once(server, 'listening')
677
- return server
678
- }