@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,551 @@
1
+ import { encode } from '@atproto/lex-cbor'
2
+ import { LexError, LexValue, isPlainObject, ui8Concat } from '@atproto/lex-data'
3
+ import { lexParse, lexToJson } from '@atproto/lex-json'
4
+ import {
5
+ InferMethodInput,
6
+ InferMethodMessage,
7
+ InferMethodOutput,
8
+ InferMethodOutputBody,
9
+ InferMethodOutputEncoding,
10
+ InferMethodParams,
11
+ Main,
12
+ NsidString,
13
+ Procedure,
14
+ Query,
15
+ Subscription,
16
+ getMain,
17
+ isNsidString,
18
+ } from '@atproto/lex-schema'
19
+ import { drainWebsocket } from './lib/drain-websocket.js'
20
+
21
+ type LexMethod = Query | Procedure | Subscription
22
+
23
+ export type NetAddr = {
24
+ hostname: string
25
+ port: number
26
+ transport: 'tcp' | 'udp'
27
+ }
28
+
29
+ export type UnixAddr = {
30
+ path: string
31
+ transport: 'unix' | 'unixpacket'
32
+ }
33
+
34
+ export type Addr = NetAddr | UnixAddr
35
+
36
+ export type ConnectionInfo = {
37
+ localAddr?: Addr
38
+ remoteAddr?: Addr
39
+ }
40
+
41
+ type Handler = (
42
+ request: Request,
43
+ connection?: ConnectionInfo,
44
+ ) => Promise<Response>
45
+
46
+ export type LexRouterHandlerContext<Method extends LexMethod, Credentials> = {
47
+ credentials: Credentials
48
+ input: InferMethodInput<Method, Body>
49
+ params: InferMethodParams<Method>
50
+ request: Request
51
+ connection?: ConnectionInfo
52
+ }
53
+
54
+ type AsOptionalPayloadOptions<T> = T extends undefined | void
55
+ ? { encoding?: undefined; body?: undefined }
56
+ : T
57
+
58
+ export type LexRouterHandlerOutput<Method extends Query | Procedure> =
59
+ | Response
60
+ | ({
61
+ headers?: HeadersInit
62
+ } & (InferMethodOutputEncoding<Method> extends 'application/json'
63
+ ? {
64
+ // Allow omitting body when output is JSON
65
+ encoding?: 'application/json'
66
+ body: InferMethodOutputBody<Method>
67
+ }
68
+ : AsOptionalPayloadOptions<InferMethodOutput<Method, BodyInit>>))
69
+
70
+ export type LexRouterMethodHandler<
71
+ Method extends Query | Procedure = Query | Procedure,
72
+ Credentials = unknown,
73
+ > = (
74
+ ctx: LexRouterHandlerContext<Method, Credentials>,
75
+ ) => Promise<LexRouterHandlerOutput<Method>>
76
+
77
+ export type LexRouterMethodConfig<
78
+ Method extends Query | Procedure = Query | Procedure,
79
+ Credentials = unknown,
80
+ > = {
81
+ handler: LexRouterMethodHandler<Method, Credentials>
82
+ auth: LexRouterAuth<Method, Credentials>
83
+ }
84
+
85
+ export type LexRouterSubscriptionHandler<
86
+ Method extends Subscription = Subscription,
87
+ Credentials = unknown,
88
+ > = (
89
+ ctx: LexRouterHandlerContext<Method, Credentials>,
90
+ ) => AsyncIterable<InferMethodMessage<Method>>
91
+
92
+ export type LexRouterSubscriptionConfig<
93
+ Method extends Subscription = Subscription,
94
+ Credentials = unknown,
95
+ > = {
96
+ handler: LexRouterSubscriptionHandler<Method, Credentials>
97
+ auth: LexRouterAuth<Method, Credentials>
98
+ }
99
+
100
+ export type LexRouterAuthContext<Method extends LexMethod = LexMethod> = {
101
+ params: InferMethodParams<Method>
102
+ request: Request
103
+ connection?: ConnectionInfo
104
+ }
105
+
106
+ export type LexRouterAuth<
107
+ Method extends LexMethod = LexMethod,
108
+ Credentials = unknown,
109
+ > = (ctx: LexRouterAuthContext<Method>) => Credentials | Promise<Credentials>
110
+
111
+ export type LexErrorHandlerContext = {
112
+ error: unknown
113
+ request: Request
114
+ method: LexMethod
115
+ }
116
+
117
+ export type UpgradeWebSocket = (request: Request) => {
118
+ socket: WebSocket
119
+ response: Response
120
+ }
121
+
122
+ export type LexRouterOptions = {
123
+ upgradeWebSocket?: UpgradeWebSocket
124
+ onHandlerError?: (ctx: LexErrorHandlerContext) => void | Promise<void>
125
+ highWaterMark?: number
126
+ lowWaterMark?: number
127
+ }
128
+
129
+ export class LexRouter {
130
+ private handlers: Map<NsidString, Handler> = new Map()
131
+
132
+ constructor(readonly options: LexRouterOptions = {}) {}
133
+
134
+ add<M extends Subscription>(
135
+ ns: Main<M>,
136
+ handler: LexRouterSubscriptionHandler<M, void>,
137
+ ): this
138
+ add<M extends Subscription, Credentials>(
139
+ ns: Main<M>,
140
+ config: LexRouterSubscriptionConfig<M, Credentials>,
141
+ ): this
142
+ add<M extends Query | Procedure>(
143
+ ns: Main<M>,
144
+ handler: LexRouterMethodHandler<M, void>,
145
+ ): this
146
+ add<M extends Query | Procedure, Credentials>(
147
+ ns: Main<M>,
148
+ config: LexRouterMethodConfig<M, Credentials>,
149
+ ): this
150
+ add<M extends LexMethod>(
151
+ ns: Main<M>,
152
+ config:
153
+ | LexRouterSubscriptionHandler<any, any>
154
+ | LexRouterSubscriptionConfig<any, any>
155
+ | LexRouterMethodHandler<any, any>
156
+ | LexRouterMethodConfig<any, any>,
157
+ ) {
158
+ const method = getMain(ns)
159
+ if (this.handlers.has(method.nsid)) {
160
+ throw new TypeError(`Method ${method.nsid} already registered`)
161
+ }
162
+ const methodConfig =
163
+ typeof config === 'function'
164
+ ? { handler: config, auth: undefined }
165
+ : config
166
+
167
+ const handler: Handler =
168
+ method.type === 'subscription'
169
+ ? this.buildSubscriptionHandler(
170
+ method,
171
+ methodConfig.handler as LexRouterSubscriptionHandler<any, any>,
172
+ methodConfig.auth,
173
+ )
174
+ : this.buildMethodHandler(
175
+ method,
176
+ methodConfig.handler as LexRouterMethodHandler<any, any>,
177
+ methodConfig.auth,
178
+ )
179
+
180
+ this.handlers.set(method.nsid, handler)
181
+
182
+ return this
183
+ }
184
+
185
+ private buildMethodHandler<Method extends Query | Procedure, Credentials>(
186
+ method: Method,
187
+ methodHandler: LexRouterMethodHandler<Method, Credentials>,
188
+ auth?: LexRouterAuth<Method, Credentials>,
189
+ ): Handler {
190
+ const getInput = (
191
+ method.type === 'procedure'
192
+ ? getProcedureInput.bind(method)
193
+ : getQueryInput.bind(method)
194
+ ) as (request: Request) => Promise<InferMethodInput<Method, Body>>
195
+
196
+ return async (
197
+ request: Request,
198
+ connection?: ConnectionInfo,
199
+ ): Promise<Response> => {
200
+ // @NOTE CORS requests should be handled by a middleware before reaching
201
+ // this point.
202
+ if (
203
+ (method.type === 'procedure' && request.method !== 'POST') ||
204
+ (method.type === 'query' &&
205
+ request.method !== 'GET' &&
206
+ request.method !== 'HEAD')
207
+ ) {
208
+ return Response.json(
209
+ { error: 'InvalidRequest', message: 'Method not allowed' },
210
+ { status: 405 },
211
+ )
212
+ }
213
+
214
+ try {
215
+ const url = new URL(request.url)
216
+ const params = method.parameters.fromURLSearchParams(url.searchParams)
217
+
218
+ const credentials = auth
219
+ ? await auth({ params, request, connection })
220
+ : (undefined as Credentials)
221
+
222
+ const input = await getInput(request)
223
+
224
+ const output = await methodHandler({
225
+ credentials,
226
+ params,
227
+ input,
228
+ request,
229
+ connection,
230
+ })
231
+
232
+ if (output instanceof Response) {
233
+ return output
234
+ }
235
+
236
+ // @TODO add validation of output based on method.output.schema?
237
+
238
+ if (output.body === undefined && output.encoding === undefined) {
239
+ return new Response(null, { status: 200, headers: output.headers })
240
+ }
241
+
242
+ if (method.output?.encoding === 'application/json') {
243
+ return Response.json(lexToJson(output.body as LexValue), {
244
+ status: 200,
245
+ headers: output.headers,
246
+ })
247
+ }
248
+
249
+ const headers = new Headers(output.headers)
250
+ headers.set('content-type', output.encoding!)
251
+ return new Response(output.body, { status: 200, headers })
252
+ } catch (error) {
253
+ return this.handleError(request, method, error)
254
+ }
255
+ }
256
+ }
257
+
258
+ private buildSubscriptionHandler<Method extends Subscription, Credentials>(
259
+ method: Method,
260
+ methodHandler: LexRouterSubscriptionHandler<Method, Credentials>,
261
+ auth?: LexRouterAuth<Method, Credentials>,
262
+ ): Handler {
263
+ const {
264
+ onHandlerError,
265
+ upgradeWebSocket = (globalThis as any).Deno?.upgradeWebSocket as
266
+ | UpgradeWebSocket
267
+ | undefined,
268
+ } = this.options
269
+ if (!upgradeWebSocket) {
270
+ throw new TypeError(
271
+ 'WebSocket upgrade not supported in this environment. Please provide an upgradeWebSocket option when creating the LexRouter.',
272
+ )
273
+ }
274
+
275
+ return async (
276
+ request: Request,
277
+ connection?: ConnectionInfo,
278
+ ): Promise<Response> => {
279
+ if (request.method !== 'GET') {
280
+ return Response.json(
281
+ { error: 'InvalidRequest', message: 'Method not allowed' },
282
+ { status: 405 },
283
+ )
284
+ }
285
+
286
+ if (
287
+ request.headers.get('connection')?.toLowerCase() !== 'upgrade' ||
288
+ request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
289
+ ) {
290
+ return Response.json(
291
+ {
292
+ error: 'InvalidRequest',
293
+ message: 'XRPC subscriptions are only available over WebSocket',
294
+ },
295
+ {
296
+ status: 426,
297
+ headers: {
298
+ Connection: 'Upgrade',
299
+ Upgrade: 'websocket',
300
+ },
301
+ },
302
+ )
303
+ }
304
+
305
+ try {
306
+ const { response, socket } = upgradeWebSocket(request)
307
+
308
+ socket.addEventListener('message', () => {
309
+ const error = new LexError(
310
+ 'InvalidRequest',
311
+ 'XRPC subscriptions do not accept messages',
312
+ )
313
+ socket.send(encodeErrorFrame(error))
314
+ socket.close(1008, error.error)
315
+ })
316
+
317
+ socket.addEventListener('open', async () => {
318
+ try {
319
+ const url = new URL(request.url)
320
+ const params = method.parameters.fromURLSearchParams(
321
+ url.searchParams,
322
+ )
323
+
324
+ const credentials: Credentials = auth
325
+ ? await auth({ params, request, connection })
326
+ : (undefined as Credentials)
327
+
328
+ request.signal.throwIfAborted()
329
+
330
+ const iterable = methodHandler({
331
+ credentials,
332
+ params,
333
+ input: undefined as InferMethodInput<Method, Body>,
334
+ request,
335
+ connection,
336
+ })
337
+
338
+ const iterator = iterable[Symbol.asyncIterator]()
339
+
340
+ if (iterator.return) {
341
+ const abort = async () => {
342
+ socket.removeEventListener('error', abort)
343
+ socket.removeEventListener('close', abort)
344
+ try {
345
+ await iterator.return!()
346
+ } catch {
347
+ // Ignore
348
+ }
349
+ }
350
+ socket.addEventListener('error', abort)
351
+ socket.addEventListener('close', abort)
352
+ }
353
+
354
+ while (socket.readyState === 1) {
355
+ const result = await iterator.next()
356
+ if (result.done) break
357
+
358
+ // Should not be needed (socket would emit "close" event)
359
+ request.signal.throwIfAborted()
360
+
361
+ // @TODO add validation of output based on method.output.schema?
362
+
363
+ const data = encodeMessageFrame(method, result.value)
364
+
365
+ socket.send(data)
366
+
367
+ // Apply backpressure by waiting for the buffered data to drain
368
+ // before generating the next message
369
+ await drainWebsocket(socket, request.signal, this.options)
370
+ }
371
+
372
+ socket.close(1000)
373
+ } catch (error) {
374
+ // If the socket is still open, send an error frame before closing
375
+ if (socket.readyState === 1) {
376
+ const lexError =
377
+ error instanceof LexError
378
+ ? error
379
+ : new LexError('InternalError', 'An internal error occurred')
380
+
381
+ socket.send(encodeErrorFrame(lexError))
382
+
383
+ socket.close(
384
+ // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
385
+ error instanceof LexError ? 1008 : 1011,
386
+ lexError.error,
387
+ )
388
+ }
389
+
390
+ // Only report unexpected processing errors
391
+ if (onHandlerError && !isAbortReason(request.signal, error)) {
392
+ await onHandlerError({ error, request, method })
393
+ }
394
+ }
395
+ })
396
+
397
+ return response
398
+ } catch (error) {
399
+ return this.handleError(request, method, error)
400
+ }
401
+ }
402
+ }
403
+
404
+ private async handleError(
405
+ request: Request,
406
+ method: LexMethod,
407
+ error: unknown,
408
+ ) {
409
+ // Only report unexpected processing errors
410
+ const { onHandlerError } = this.options
411
+ if (onHandlerError && !isAbortReason(request.signal, error)) {
412
+ await onHandlerError({ error, request, method })
413
+ }
414
+
415
+ if (error instanceof LexError) {
416
+ return error.toResponse()
417
+ }
418
+
419
+ return Response.json(
420
+ { error: 'InternalError', message: 'An internal error occurred' },
421
+ { status: 500 },
422
+ )
423
+ }
424
+
425
+ handle: Handler = async (
426
+ request: Request,
427
+ connection?: ConnectionInfo,
428
+ ): Promise<Response> => {
429
+ const nsid = extractMethodNsid(request)
430
+
431
+ const handler = (this.handlers as Map<string | null, Handler>).get(nsid)
432
+ if (handler) return handler(request, connection)
433
+
434
+ if (!nsid || !isNsidString(nsid)) {
435
+ return Response.json(
436
+ {
437
+ error: 'InvalidRequest',
438
+ message: 'Invalid XRPC method path',
439
+ },
440
+ { status: 404 },
441
+ )
442
+ }
443
+
444
+ return Response.json(
445
+ {
446
+ error: 'MethodNotImplemented',
447
+ message: `XRPC method "${nsid}" not implemented on this server`,
448
+ },
449
+ { status: 501 },
450
+ )
451
+ }
452
+ }
453
+
454
+ function extractMethodNsid(request: Request): string | null {
455
+ const { pathname } = new URL(request.url)
456
+ if (!pathname.startsWith('/xrpc/')) return null
457
+ if (pathname.includes('/', 6)) return null
458
+ // We don't really need to validate the NSID here, the existence of the route
459
+ // (which is looked up based on an NSID) is sufficient.
460
+ return pathname.slice(6)
461
+ }
462
+
463
+ async function getProcedureInput<M extends Procedure>(
464
+ this: M,
465
+ request: Request,
466
+ ): Promise<InferMethodInput<M, Body>> {
467
+ const encodingRaw = request.headers
468
+ .get('content-type')
469
+ ?.split(';')[0]
470
+ .trim()
471
+ .toLowerCase()
472
+
473
+ const encoding =
474
+ encodingRaw ||
475
+ // If the caller did not provide a content-type, but the method
476
+ // expects an input, assume binary
477
+ (request.body != null && this.input.encoding != null
478
+ ? 'application/octet-stream'
479
+ : undefined)
480
+
481
+ if (!this.input.matchesEncoding(encoding)) {
482
+ throw new LexError('InvalidRequest', `Invalid content-type: ${encoding}`)
483
+ }
484
+
485
+ if (this.input.encoding === 'application/json') {
486
+ // @TODO limit size?
487
+ const body = this.input.schema
488
+ ? this.input.schema.parse(lexParse(await request.text()))
489
+ : lexParse(await request.text())
490
+ return { encoding, body } as InferMethodInput<M, Body>
491
+ } else if (this.input.encoding) {
492
+ const body: Body = request
493
+ return { encoding, body } as InferMethodInput<M, Body>
494
+ } else {
495
+ return undefined as InferMethodInput<M, Body>
496
+ }
497
+ }
498
+
499
+ async function getQueryInput<M extends Query>(
500
+ this: M,
501
+ request: Request,
502
+ ): Promise<InferMethodInput<M, Body>> {
503
+ if (
504
+ request.body ||
505
+ request.headers.has('content-type') ||
506
+ request.headers.has('content-length')
507
+ ) {
508
+ throw new LexError('InvalidRequest', 'GET requests must not have a body')
509
+ }
510
+
511
+ return undefined as InferMethodInput<M, Body>
512
+ }
513
+
514
+ // Pre-encoded frame header for error frames
515
+ const ERROR_FRAME_HEADER = /*#__PURE__*/ encode({ op: -1 })
516
+
517
+ function encodeErrorFrame(error: LexError): Uint8Array {
518
+ return ui8Concat([ERROR_FRAME_HEADER, encode(error.toJSON())])
519
+ }
520
+
521
+ // Pre-encoded frame header for message frames with unknown type
522
+ const UNKNOWN_MESSAGE_FRAME_HEADER = /*#__PURE__*/ encode({ op: 1 })
523
+
524
+ function encodeMessageFrame(method: Subscription, value: LexValue): Uint8Array {
525
+ if (isPlainObject(value) && typeof value.$type === 'string') {
526
+ const { $type, ...rest } = value
527
+ return ui8Concat([
528
+ encode({
529
+ op: 1,
530
+ t:
531
+ // If $type starts with `nsid#`, strip the NSID prefix
532
+ $type.charCodeAt(0) !== 0x23 && // '#'
533
+ $type.charCodeAt(method.nsid.length) === 0x23 && // '#'
534
+ $type.startsWith(method.nsid)
535
+ ? $type.slice(method.nsid.length)
536
+ : $type,
537
+ }),
538
+ encode(rest),
539
+ ])
540
+ }
541
+
542
+ return ui8Concat([UNKNOWN_MESSAGE_FRAME_HEADER, encode(value)])
543
+ }
544
+
545
+ function isAbortReason(signal: AbortSignal, error: unknown): boolean {
546
+ if (!signal.aborted || signal.reason == null) return false
547
+ return (
548
+ error === signal.reason ||
549
+ (error instanceof Error && error.cause === signal.reason)
550
+ )
551
+ }
@@ -0,0 +1,23 @@
1
+ import { abortableSleep } from './sleep.js'
2
+
3
+ export async function drainWebsocket(
4
+ socket: WebSocket,
5
+ signal: AbortSignal,
6
+ {
7
+ highWaterMark = 250_000, // 250 KB
8
+ lowWaterMark = 50_000, // 50 KB
9
+ }: {
10
+ highWaterMark?: number
11
+ lowWaterMark?: number
12
+ } = {},
13
+ ): Promise<void> {
14
+ if (socket.bufferedAmount > highWaterMark) {
15
+ while (
16
+ socket.readyState === 1 &&
17
+ socket.bufferedAmount !== 0 &&
18
+ socket.bufferedAmount > lowWaterMark
19
+ ) {
20
+ await abortableSleep(10, signal)
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,25 @@
1
+ export async function abortableSleep(
2
+ ms: number,
3
+ signal: AbortSignal,
4
+ ): Promise<void> {
5
+ signal.throwIfAborted()
6
+
7
+ return new Promise((resolve, reject) => {
8
+ const cleanup = () => {
9
+ signal.removeEventListener('abort', onAbort)
10
+ clearTimeout(timeoutHandle)
11
+ }
12
+
13
+ const timeoutHandle = setTimeout(() => {
14
+ cleanup()
15
+ resolve()
16
+ }, ms)
17
+
18
+ const onAbort = () => {
19
+ cleanup()
20
+ reject(signal.reason)
21
+ }
22
+
23
+ signal.addEventListener('abort', onAbort)
24
+ })
25
+ }
@@ -0,0 +1,26 @@
1
+ export type WWWAuthenticate = {
2
+ [authScheme in string]?:
3
+ | string // token68
4
+ | { [authParam in string]?: string }
5
+ }
6
+
7
+ export function formatWWWAuthenticateHeader(
8
+ wwwAuthenticate: WWWAuthenticate,
9
+ ): string {
10
+ return Object.entries(wwwAuthenticate)
11
+ .map(([authScheme, authParams]) => {
12
+ if (authParams === undefined) return null
13
+ const paramsEnc =
14
+ typeof authParams === 'string'
15
+ ? [authParams]
16
+ : Object.entries(authParams)
17
+ .filter(([_, val]) => val != null)
18
+ .map(([name, val]) => `${name}=${JSON.stringify(val)}`)
19
+ const authChallenge = paramsEnc?.length
20
+ ? `${authScheme} ${paramsEnc.join(', ')}`
21
+ : authScheme
22
+ return authChallenge
23
+ })
24
+ .filter(Boolean)
25
+ .join(', ')
26
+ }