@atproto/lex-server 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE.txt +7 -0
  3. package/README.md +598 -0
  4. package/dist/errors.d.ts +13 -0
  5. package/dist/errors.d.ts.map +1 -0
  6. package/dist/errors.js +39 -0
  7. package/dist/errors.js.map +1 -0
  8. package/dist/example.d.ts +2 -0
  9. package/dist/example.d.ts.map +1 -0
  10. package/dist/example.js +36 -0
  11. package/dist/example.js.map +1 -0
  12. package/dist/index.d.ts +4 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +9 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/lex-auth-error.d.ts +15 -0
  17. package/dist/lex-auth-error.d.ts.map +1 -0
  18. package/dist/lex-auth-error.js +52 -0
  19. package/dist/lex-auth-error.js.map +1 -0
  20. package/dist/lex-server.d.ts +80 -0
  21. package/dist/lex-server.d.ts.map +1 -0
  22. package/dist/lex-server.js +285 -0
  23. package/dist/lex-server.js.map +1 -0
  24. package/dist/lib/drain-websocket.d.ts +6 -0
  25. package/dist/lib/drain-websocket.d.ts.map +1 -0
  26. package/dist/lib/drain-websocket.js +16 -0
  27. package/dist/lib/drain-websocket.js.map +1 -0
  28. package/dist/lib/sleep.d.ts +2 -0
  29. package/dist/lib/sleep.d.ts.map +1 -0
  30. package/dist/lib/sleep.js +22 -0
  31. package/dist/lib/sleep.js.map +1 -0
  32. package/dist/lib/www-authenticate.d.ts +7 -0
  33. package/dist/lib/www-authenticate.d.ts.map +1 -0
  34. package/dist/lib/www-authenticate.js +22 -0
  35. package/dist/lib/www-authenticate.js.map +1 -0
  36. package/dist/nodejs.d.ts +35 -0
  37. package/dist/nodejs.d.ts.map +1 -0
  38. package/dist/nodejs.js +236 -0
  39. package/dist/nodejs.js.map +1 -0
  40. package/dist/subscripotion.d.ts +2 -0
  41. package/dist/subscripotion.d.ts.map +1 -0
  42. package/dist/subscripotion.js +36 -0
  43. package/dist/subscripotion.js.map +1 -0
  44. package/dist/test.d.mts +2 -0
  45. package/dist/test.d.mts.map +1 -0
  46. package/dist/test.mjs +52 -0
  47. package/dist/test.mjs.map +1 -0
  48. package/nodejs.js +5 -0
  49. package/package.json +64 -0
  50. package/src/errors.ts +54 -0
  51. package/src/index.ts +8 -0
  52. package/src/lex-server.test.ts +1621 -0
  53. package/src/lex-server.ts +551 -0
  54. package/src/lib/drain-websocket.ts +23 -0
  55. package/src/lib/sleep.ts +25 -0
  56. package/src/lib/www-authenticate.ts +26 -0
  57. package/src/nodejs.test.ts +107 -0
  58. package/src/nodejs.ts +367 -0
  59. package/tsconfig.build.json +12 -0
  60. package/tsconfig.json +8 -0
  61. package/tsconfig.tests.json +9 -0
@@ -0,0 +1,107 @@
1
+ import { AddressInfo } from 'node:net'
2
+ import { scheduler } from 'node:timers/promises'
3
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
4
+ import { Server, serve } from './nodejs.js'
5
+
6
+ describe('Node.js RequestListener', () => {
7
+ let server: Server
8
+ let address: string
9
+
10
+ beforeAll(async () => {
11
+ server = await serve(async (request) => {
12
+ const { pathname } = new URL(request.url)
13
+ if (pathname === '/hello') {
14
+ return new Response('Hello, world!', {
15
+ status: 200,
16
+ headers: { 'Content-Type': 'text/plain' },
17
+ })
18
+ } else if (pathname === '/throw') {
19
+ throw new Error('Test error')
20
+ } else if (pathname === '/echo') {
21
+ return new Response(request.body, {
22
+ status: 200,
23
+ headers: { 'Content-Type': 'application/octet-stream' },
24
+ })
25
+ }
26
+ return new Response('Not Found', { status: 404 })
27
+ })
28
+ const { port } = server.address() as AddressInfo
29
+ address = `http://localhost:${port}`
30
+ })
31
+
32
+ afterAll(async () => {
33
+ await server.terminate()
34
+ })
35
+
36
+ it('should respond with Hello, world! on /hello', async () => {
37
+ const res = await fetch(new URL(`/hello`, address))
38
+ const text = await res.text()
39
+ expect(res.status).toBe(200)
40
+ expect(text).toBe('Hello, world!')
41
+ })
42
+
43
+ it('should respond with Not Found on unknown path', async () => {
44
+ const res = await fetch(new URL(`/unknown`, address))
45
+ const text = await res.text()
46
+ expect(res.status).toBe(404)
47
+ expect(text).toBe('Not Found')
48
+ })
49
+
50
+ it('should handle thrown errors and respond with 500', async () => {
51
+ const res = await fetch(new URL(`/throw`, address))
52
+ const text = await res.text()
53
+ expect(res.status).toBe(500)
54
+ expect(text).toBe('Internal Server Error')
55
+ })
56
+
57
+ it('should handle streaming bodies', async () => {
58
+ const totalSize = 1024 * 1024
59
+ const consumerSize = 42 * 1024
60
+
61
+ let sentBytes = 0
62
+ let receivedBytes = 0
63
+
64
+ const res = await fetch(new URL(`/echo`, address), {
65
+ method: 'POST',
66
+ // @ts-expect-error
67
+ duplex: 'half',
68
+ body: new ReadableStream({
69
+ async pull(controller) {
70
+ const chunkSize = Math.min(1024, totalSize - sentBytes)
71
+ controller.enqueue('A'.repeat(chunkSize))
72
+ sentBytes += chunkSize
73
+ await scheduler.wait(0) // Yield to event loop
74
+ if (sentBytes === totalSize) controller.close()
75
+ },
76
+ }),
77
+ })
78
+
79
+ const reader = res.body!.getReader()
80
+
81
+ // eslint-disable-next-line no-constant-condition
82
+ while (true) {
83
+ const result = await reader.read()
84
+ if (result.done) break
85
+ receivedBytes += Buffer.byteLength(result.value)
86
+ if (receivedBytes >= consumerSize) {
87
+ await reader.cancel()
88
+ break
89
+ }
90
+ }
91
+
92
+ expect(receivedBytes).toBeGreaterThanOrEqual(consumerSize)
93
+ expect(sentBytes).toBeGreaterThanOrEqual(consumerSize)
94
+ expect(sentBytes).toBeLessThan(totalSize)
95
+ })
96
+
97
+ it('should echo back request body on /echo', async () => {
98
+ const body = `Echo this back`
99
+ const res = await fetch(new URL(`/echo`, address), {
100
+ method: 'POST',
101
+ body,
102
+ })
103
+ const text = await res.text()
104
+ expect(res.status).toBe(200)
105
+ expect(text).toBe(body)
106
+ })
107
+ })
package/src/nodejs.ts ADDED
@@ -0,0 +1,367 @@
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 { createHttpTerminator } from 'http-terminator'
15
+ import { WebSocket as WebSocketPonyfill, WebSocketServer } from 'ws'
16
+
17
+ // @ts-expect-error
18
+ Symbol.asyncDispose ??= Symbol.for('Symbol.asyncDispose')
19
+
20
+ const kResponseWs = Symbol.for('@atproto/lex-server:WebSocket')
21
+
22
+ function isUpgradeRequest(request: Request, upgrade: string): boolean {
23
+ return (
24
+ request.method === 'GET' &&
25
+ request.headers.get('connection')?.toLowerCase() === 'upgrade' &&
26
+ request.headers.get('upgrade')?.toLowerCase() === upgrade
27
+ )
28
+ }
29
+
30
+ export function upgradeWebSocket(request: Request): {
31
+ response: Response
32
+ socket: WebSocket
33
+ } {
34
+ if (!isUpgradeRequest(request, 'websocket')) {
35
+ throw new TypeError('upgradeWebSocket() expects a WebSocket upgrade')
36
+ }
37
+
38
+ // Placeholder response for WebSocket upgrade. The actual handling will happen
39
+ // through the handleWebSocketUpgrade function. Headers set on the response
40
+ // will be applied during the upgrade.
41
+ const response = new Response(null, { status: 200 })
42
+
43
+ // The Response constructor does not allow setting status 101, so we
44
+ // define it directly. The purpose of this response is just to signal
45
+ // that an upgrade is needed, and to carry any headers.
46
+ Object.defineProperty(response, 'status', {
47
+ value: 101,
48
+ enumerable: false,
49
+ configurable: false,
50
+ writable: false,
51
+ })
52
+
53
+ // @ts-expect-error
54
+ const socket: WebSocket = new WebSocketPonyfill(null, undefined, {
55
+ autoPong: true,
56
+ })
57
+
58
+ // Attach the WebSocket to the response for later retrieval
59
+ Object.defineProperty(response, kResponseWs, {
60
+ value: socket,
61
+ enumerable: false,
62
+ configurable: false,
63
+ writable: false,
64
+ })
65
+
66
+ return { response, socket }
67
+ }
68
+
69
+ function handleWebSocketUpgrade(
70
+ req: IncomingMessage,
71
+ response: Response,
72
+ ): void {
73
+ const ws = (response as { [kResponseWs]?: WebSocketPonyfill })[kResponseWs]
74
+ if (!ws) throw new TypeError('Response not created by upgradeWebSocket()')
75
+
76
+ // Create a one time use WebSocketServer to handle the upgrade
77
+ const wss = new WebSocketServer({
78
+ autoPong: true,
79
+ noServer: true,
80
+ clientTracking: false,
81
+ perMessageDeflate: true,
82
+ // @ts-expect-error
83
+ WebSocket: function () {
84
+ // Return the websocket that was created earlier instead of a new instance
85
+ return ws
86
+ },
87
+ })
88
+
89
+ // Apply headers that might have been set on the response object during
90
+ // handling. This will be called during wss.handleUpgrade().
91
+ wss.on('headers', (headers) => {
92
+ for (const [name, value] of response.headers) {
93
+ headers.push(`${name}: ${value}`)
94
+ }
95
+ })
96
+
97
+ wss.handleUpgrade(req, req.socket, Buffer.alloc(0), (_socket) => {
98
+ // @TODO find a way to properly "close" the _socket when the server is
99
+ // shutting down (might require replacing http-terminator with a local
100
+ // implementation)
101
+ })
102
+ }
103
+
104
+ async function sendResponse(
105
+ req: IncomingMessage,
106
+ res: ServerResponse,
107
+ response: Response,
108
+ ): Promise<void> {
109
+ // Invalid usage
110
+ if (res.headersSent) {
111
+ throw new TypeError('Response has already been sent')
112
+ }
113
+
114
+ if (response.status === 101) {
115
+ return handleWebSocketUpgrade(req, response)
116
+ }
117
+
118
+ res.statusCode = response.status
119
+ res.statusMessage = response.statusText
120
+
121
+ for (const [key, value] of response.headers) {
122
+ res.appendHeader(key, value)
123
+ }
124
+
125
+ if (response.body != null && req.method !== 'HEAD') {
126
+ const stream = Readable.fromWeb(response.body as any)
127
+ await pipeline(stream, res)
128
+ } else {
129
+ await response.body?.cancel()
130
+ res.end()
131
+ }
132
+ }
133
+
134
+ function toRequest(req: IncomingMessage): Request {
135
+ const host = req.headers.host ?? req.socket?.localAddress ?? 'localhost'
136
+ const isEncrypted = (req.socket as any).encrypted === true
137
+ const protocol = isEncrypted ? 'https' : 'http'
138
+ const url = new URL(req.url ?? '/', `${protocol}://${host}`)
139
+ const headers = toHeaders(req.headers)
140
+ const body = toBody(req)
141
+
142
+ const abortController = new AbortController()
143
+ const abort = (err?: Error) => abortController.abort(err)
144
+
145
+ req.on('close', abort)
146
+ req.on('error', abort)
147
+ req.on('end', abort)
148
+
149
+ abortController.signal.addEventListener(
150
+ 'abort',
151
+ () => {
152
+ req.off('close', abort)
153
+ req.off('error', abort)
154
+ req.off('end', abort)
155
+ },
156
+ { once: true },
157
+ )
158
+
159
+ return new Request(url, {
160
+ signal: abortController.signal,
161
+ method: req.method,
162
+ headers,
163
+ body,
164
+ referrer: headers.get('referrer') ?? headers.get('referer') ?? undefined,
165
+ redirect: 'manual',
166
+ // @ts-expect-error
167
+ duplex: body ? 'half' : undefined,
168
+ })
169
+ }
170
+
171
+ function toHeaders(headers: IncomingHttpHeaders): Headers {
172
+ const result = new Headers()
173
+ for (const [key, value] of Object.entries(headers)) {
174
+ if (value === undefined) continue
175
+ if (Array.isArray(value)) {
176
+ for (const v of value) result.append(key, v)
177
+ } else {
178
+ result.set(key, value)
179
+ }
180
+ }
181
+ return result
182
+ }
183
+
184
+ function toBody(req: IncomingMessage): null | ReadableStream<Uint8Array> {
185
+ if (
186
+ req.method === 'GET' ||
187
+ req.method === 'HEAD' ||
188
+ req.method === 'OPTIONS'
189
+ ) {
190
+ return null
191
+ }
192
+
193
+ if (
194
+ req.headers['content-type'] == null &&
195
+ req.headers['transfer-encoding'] == null &&
196
+ req.headers['content-length'] == null
197
+ ) {
198
+ return null
199
+ }
200
+
201
+ return Readable.toWeb(req) as ReadableStream<Uint8Array>
202
+ }
203
+
204
+ export type NetAddr = {
205
+ hostname: string
206
+ port: number
207
+ transport: 'tcp'
208
+ }
209
+
210
+ export type NodeConnectionInfo = {
211
+ localAddr?: NetAddr
212
+ remoteAddr?: NetAddr
213
+ }
214
+
215
+ export interface HandlerFunction {
216
+ (req: Request, info: NodeConnectionInfo): Response | Promise<Response>
217
+ }
218
+
219
+ export interface HandlerObject {
220
+ handle: HandlerFunction
221
+ }
222
+
223
+ async function handleRequest(
224
+ req: IncomingMessage,
225
+ res: ServerResponse,
226
+ handlerFn: HandlerFunction,
227
+ ) {
228
+ const request = toRequest(req)
229
+ const info = toConnectionInfo(req)
230
+ const response = await handlerFn(request, info)
231
+ await sendResponse(req, res, response)
232
+ }
233
+
234
+ function toConnectionInfo(req: IncomingMessage): NodeConnectionInfo {
235
+ const { socket } = req
236
+ return {
237
+ localAddr:
238
+ socket.localAddress != null
239
+ ? {
240
+ hostname: socket.localAddress,
241
+ port: socket.localPort!,
242
+ transport: 'tcp',
243
+ }
244
+ : undefined,
245
+ remoteAddr:
246
+ socket.remoteAddress != null
247
+ ? {
248
+ hostname: socket.remoteAddress,
249
+ port: socket.remotePort!,
250
+ transport: 'tcp',
251
+ }
252
+ : undefined,
253
+ }
254
+ }
255
+
256
+ export function toRequestListener<
257
+ Request extends typeof IncomingMessage = typeof IncomingMessage,
258
+ Response extends typeof ServerResponse<
259
+ InstanceType<Request>
260
+ > = typeof ServerResponse,
261
+ >(handlerFn: HandlerFunction) {
262
+ return ((
263
+ req: InstanceType<Request>,
264
+ res: InstanceType<Response> & { req: InstanceType<Request> },
265
+ next?: (err?: unknown) => void,
266
+ ): void => {
267
+ handleRequest(req, res, handlerFn).catch((err) => {
268
+ if (next) next(err)
269
+ else {
270
+ if (!res.headersSent) {
271
+ res.statusCode = 500
272
+ res.setHeader('content-type', 'text/plain; charset=utf-8')
273
+ res.end('Internal Server Error')
274
+ } else if (!res.writableEnded) {
275
+ res.destroy()
276
+ }
277
+ }
278
+ })
279
+ }) satisfies RequestListener<Request, Response>
280
+ }
281
+
282
+ export type CreateServerOptions<
283
+ Request extends typeof IncomingMessage = typeof IncomingMessage,
284
+ Response extends typeof ServerResponse<
285
+ InstanceType<Request>
286
+ > = typeof ServerResponse,
287
+ > = ServerOptions<Request, Response> & {
288
+ gracefulTerminationTimeout?: number
289
+ }
290
+
291
+ export interface Server<
292
+ Request extends typeof IncomingMessage = typeof IncomingMessage,
293
+ Response extends typeof ServerResponse<
294
+ InstanceType<Request>
295
+ > = typeof ServerResponse,
296
+ > extends HttpServer<Request, Response>,
297
+ AsyncDisposable {
298
+ terminate(): Promise<void>
299
+ [Symbol.asyncDispose](): Promise<void>
300
+ }
301
+
302
+ export function createServer<
303
+ Request extends typeof IncomingMessage = typeof IncomingMessage,
304
+ Response extends typeof ServerResponse<
305
+ InstanceType<Request>
306
+ > = typeof ServerResponse,
307
+ >(
308
+ handler: HandlerFunction | HandlerObject,
309
+ options: CreateServerOptions<Request, Response> = {},
310
+ ): Server<Request, Response> {
311
+ const handlerFn =
312
+ typeof handler === 'function' ? handler : handler.handle.bind(handler)
313
+
314
+ const listener = toRequestListener(handlerFn)
315
+ const server = createHttpServer(options, listener)
316
+
317
+ const terminator = createHttpTerminator({
318
+ server: server as HttpServer,
319
+ gracefulTerminationTimeout: options?.gracefulTerminationTimeout,
320
+ })
321
+
322
+ const terminate = async function terminate(this: Server<Request, Response>) {
323
+ if (this !== server) {
324
+ throw new TypeError('Server.terminate called with incorrect context')
325
+ }
326
+ // @TODO properly close all active WebSocket connections
327
+ return terminator.terminate()
328
+ }
329
+
330
+ Object.defineProperty(server, 'terminate', {
331
+ value: terminate,
332
+ enumerable: false,
333
+ configurable: false,
334
+ writable: false,
335
+ })
336
+
337
+ Object.defineProperty(server, Symbol.asyncDispose, {
338
+ value: terminate,
339
+ enumerable: false,
340
+ configurable: false,
341
+ writable: false,
342
+ })
343
+
344
+ return server as Server<Request, Response>
345
+ }
346
+
347
+ export type StartServerOptions<
348
+ Request extends typeof IncomingMessage = typeof IncomingMessage,
349
+ Response extends typeof ServerResponse<
350
+ InstanceType<Request>
351
+ > = typeof ServerResponse,
352
+ > = ListenOptions & CreateServerOptions<Request, Response>
353
+
354
+ export async function serve<
355
+ Request extends typeof IncomingMessage = typeof IncomingMessage,
356
+ Response extends typeof ServerResponse<
357
+ InstanceType<Request>
358
+ > = typeof ServerResponse,
359
+ >(
360
+ handler: HandlerFunction | HandlerObject,
361
+ options?: StartServerOptions<Request, Response>,
362
+ ): Promise<Server<Request, Response>> {
363
+ const server = createServer(handler, options)
364
+ server.listen(options)
365
+ await once(server, 'listening')
366
+ return server
367
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": ["../../../tsconfig/isomorphic.json"],
3
+ "include": ["./src"],
4
+ "exclude": ["**/*.test.ts"],
5
+ "compilerOptions": {
6
+ "noImplicitAny": true,
7
+ "importHelpers": true,
8
+ "target": "ES2023",
9
+ "rootDir": "./src",
10
+ "outDir": "./dist"
11
+ }
12
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "include": [],
3
+ "references": [
4
+ { "path": "./tsconfig.build.json" },
5
+ { "path": "./tsconfig.examples.json" },
6
+ { "path": "./tsconfig.tests.json" }
7
+ ]
8
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig/vitest.json",
3
+ "include": ["./tests", "./src/**/*.test.ts"],
4
+ "compilerOptions": {
5
+ "noImplicitAny": true,
6
+ "rootDir": "./",
7
+ "baseUrl": "./"
8
+ }
9
+ }