@atproto/lex-server 0.0.11 → 0.0.12

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 (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +38 -21
  3. package/dist/errors.d.ts +28 -58
  4. package/dist/errors.d.ts.map +1 -1
  5. package/dist/errors.js +72 -72
  6. package/dist/errors.js.map +1 -1
  7. package/dist/index.d.ts +1 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/{lex-server.d.ts → lex-router.d.ts} +55 -21
  12. package/dist/lex-router.d.ts.map +1 -0
  13. package/dist/{lex-server.js → lex-router.js} +169 -73
  14. package/dist/lex-router.js.map +1 -0
  15. package/dist/lib/drain-websocket.d.ts +7 -0
  16. package/dist/lib/drain-websocket.d.ts.map +1 -1
  17. package/dist/lib/drain-websocket.js +11 -0
  18. package/dist/lib/drain-websocket.js.map +1 -1
  19. package/dist/lib/www-authenticate.d.ts +4 -3
  20. package/dist/lib/www-authenticate.d.ts.map +1 -1
  21. package/dist/lib/www-authenticate.js +29 -16
  22. package/dist/lib/www-authenticate.js.map +1 -1
  23. package/dist/nodejs.d.ts +1 -1
  24. package/dist/nodejs.d.ts.map +1 -1
  25. package/dist/nodejs.js +1 -1
  26. package/dist/nodejs.js.map +1 -1
  27. package/dist/service-auth.d.ts +1 -1
  28. package/dist/service-auth.d.ts.map +1 -1
  29. package/dist/service-auth.js.map +1 -1
  30. package/package.json +8 -7
  31. package/src/errors.test.ts +262 -0
  32. package/src/errors.ts +103 -78
  33. package/src/index.ts +1 -7
  34. package/src/{lex-server.test.ts → lex-router.test.ts} +591 -24
  35. package/src/{lex-server.ts → lex-router.ts} +275 -119
  36. package/src/lib/drain-websocket.ts +11 -0
  37. package/src/lib/www-authenticate.test.ts +134 -0
  38. package/src/lib/www-authenticate.ts +36 -17
  39. package/src/nodejs.ts +2 -2
  40. package/src/service-auth.ts +1 -1
  41. package/dist/lex-server.d.ts.map +0 -1
  42. package/dist/lex-server.js.map +0 -1
@@ -1,7 +1,14 @@
1
1
  import { encode } from '@atproto/lex-cbor'
2
- import { LexError, LexValue, isPlainObject, ui8Concat } from '@atproto/lex-data'
2
+ import {
3
+ LexError,
4
+ LexErrorData,
5
+ LexValue,
6
+ isPlainObject,
7
+ ui8Concat,
8
+ } from '@atproto/lex-data'
3
9
  import { lexParse, lexToJson } from '@atproto/lex-json'
4
10
  import {
11
+ DidString,
5
12
  InferMethodInput,
6
13
  InferMethodMessage,
7
14
  InferMethodOutput,
@@ -14,10 +21,15 @@ import {
14
21
  Query,
15
22
  Subscription,
16
23
  getMain,
24
+ isDidString,
17
25
  isNsidString,
18
26
  } from '@atproto/lex-schema'
27
+ import { LexServerError } from './errors.js'
19
28
  import { drainWebsocket } from './lib/drain-websocket.js'
20
29
 
30
+ const XRPC_PATH_PREFIX = '/xrpc/'
31
+ const XRPC_HEALTH_CHECK_PATH = '/xrpc/_health'
32
+
21
33
  type Awaitable<T> = T | Promise<T>
22
34
 
23
35
  /**
@@ -326,7 +338,7 @@ export type LexRouterSubscriptionConfig<
326
338
  * ```typescript
327
339
  * const authHandler: LexRouterAuth<UserCredentials> = async (ctx) => {
328
340
  * const token = ctx.request.headers.get('authorization')
329
- * if (!token) throw new LexError('AuthenticationRequired', 'Missing token')
341
+ * if (!token) throw new LexServerAuthError('AuthenticationRequired', 'Missing token')
330
342
  * return { userId: await verifyToken(token) }
331
343
  * }
332
344
  * ```
@@ -356,8 +368,9 @@ export type LexRouterAuthContext<Method extends LexMethod = LexMethod> = {
356
368
  * // Simple token-based auth
357
369
  * const tokenAuth: LexRouterAuth<{ userId: string }> = async ({ request }) => {
358
370
  * const token = request.headers.get('authorization')?.replace('Bearer ', '')
359
- * if (!token) throw new LexError('AuthenticationRequired', 'Token required')
371
+ * if (!token) throw new LexServerAuthError('AuthenticationRequired', 'Token required')
360
372
  * const userId = await verifyToken(token)
373
+ * if (!userId) throw new LexServerAuthError('AuthenticationRequired', 'Invalid token')
361
374
  * return { userId }
362
375
  * }
363
376
  *
@@ -376,15 +389,24 @@ export type LexRouterAuth<
376
389
  *
377
390
  * Used for logging and monitoring errors that occur during request handling.
378
391
  */
379
- export type LexErrorHandlerContext = {
380
- /** The error that was thrown during handling. */
381
- error: unknown
382
- /** The original HTTP request that triggered the error. */
392
+ export type HandlerErrorContext = {
383
393
  request: Request
384
- /** The Lexicon method that was being executed. */
385
394
  method: LexMethod
395
+ error: LexServerError
386
396
  }
387
397
 
398
+ export type HandlerErrorHook = (
399
+ ctx: HandlerErrorContext,
400
+ ) => void | Promise<void>
401
+
402
+ export type SocketErrorContext = {
403
+ request: Request
404
+ method: Subscription
405
+ error: unknown
406
+ }
407
+
408
+ export type SocketErrorHook = (ctx: SocketErrorContext) => void | Promise<void>
409
+
388
410
  /**
389
411
  * Function that upgrades an HTTP request to a WebSocket connection.
390
412
  *
@@ -409,6 +431,10 @@ export type UpgradeWebSocket = (request: Request) => {
409
431
  response: Response
410
432
  }
411
433
 
434
+ export type HealthCheckHandler = (
435
+ request: Request,
436
+ ) => Awaitable<{ [x: string]: unknown; status: 'ok' }>
437
+
412
438
  /**
413
439
  * Configuration options for the {@link LexRouter}.
414
440
  *
@@ -427,25 +453,45 @@ export type UpgradeWebSocket = (request: Request) => {
427
453
  */
428
454
  export type LexRouterOptions = {
429
455
  /**
430
- * Function to upgrade HTTP requests to WebSocket connections.
431
- * Required for subscription methods. Defaults to Deno's built-in
432
- * upgradeWebSocket if available.
456
+ * Function to upgrade HTTP requests to WebSocket connections. Required for
457
+ * subscription methods. Defaults to Deno's built-in
458
+ * {@link globalThis.upgradeWebSocket} if available. For NodeJS, use the
459
+ * homonymous export from `@atproto/lex-server/nodejs`.
433
460
  */
434
461
  upgradeWebSocket?: UpgradeWebSocket
435
462
  /**
436
- * Callback invoked when an error occurs during request handling.
437
- * Useful for logging and error reporting. Not called for client-induced
438
- * errors (e.g., request abortion).
463
+ * Callback invoked when an error occurs during request handling. Useful for
464
+ * logging and error reporting. Not called for client-induced errors (e.g.,
465
+ * request abortion).
439
466
  */
440
- onHandlerError?: (ctx: LexErrorHandlerContext) => void | Promise<void>
467
+ onHandlerError?: HandlerErrorHook
441
468
  /**
442
- * High water mark for WebSocket backpressure (in bytes).
443
- * When buffered data exceeds this, the handler will wait before sending more.
469
+ * Optional hook for handling errors during generation of WebSocket messages.
470
+ */
471
+ onSocketError?: SocketErrorHook
472
+ /**
473
+ * Optional health check handler. If provided, this function will be called
474
+ * for requests to the /xrpc/_health endpoint, allowing for custom health
475
+ * check logic and responses.
476
+ *
477
+ * If not provided, the server will respond to /xrpc/_health requests with a
478
+ * default JSON response of `{ status: 'ok' }`.
479
+ */
480
+ healthCheck?: HealthCheckHandler
481
+ /**
482
+ * Optional fallback handler for requests that are not /xrpc/ paths. Can be
483
+ * used to serve static files or other routes. If not provided, non-/xrpc/
484
+ * requests will return 404 responses.
485
+ */
486
+ fallback?: FetchHandler
487
+ /**
488
+ * High water mark for WebSocket backpressure (in bytes). When buffered data
489
+ * exceeds this, the handler will wait before sending more.
444
490
  */
445
491
  highWaterMark?: number
446
492
  /**
447
- * Low water mark for WebSocket backpressure (in bytes).
448
- * The handler resumes sending when buffered data drops below this.
493
+ * Low water mark for WebSocket backpressure (in bytes). The handler resumes
494
+ * sending when buffered data drops below this.
449
495
  */
450
496
  lowWaterMark?: number
451
497
  }
@@ -622,15 +668,18 @@ export class LexRouter {
622
668
  | LexRouterMethodConfig<any, any>,
623
669
  ) {
624
670
  const method = getMain(ns)
625
- if (this.handlers.has(method.nsid)) {
671
+ const nsid = normalizeNsid(method.nsid)
672
+
673
+ if (this.handlers.has(nsid)) {
626
674
  throw new TypeError(`Method ${method.nsid} already registered`)
627
675
  }
676
+
628
677
  const methodConfig =
629
678
  typeof config === 'function'
630
679
  ? { handler: config, auth: undefined }
631
680
  : config
632
681
 
633
- const fetch: FetchHandler =
682
+ const handler: FetchHandler =
634
683
  method.type === 'subscription'
635
684
  ? this.buildSubscriptionHandler(
636
685
  method,
@@ -643,7 +692,7 @@ export class LexRouter {
643
692
  methodConfig.auth,
644
693
  )
645
694
 
646
- this.handlers.set(method.nsid, fetch)
695
+ this.handlers.set(nsid, handler)
647
696
 
648
697
  return this
649
698
  }
@@ -668,10 +717,7 @@ export class LexRouter {
668
717
  request.method !== 'GET' &&
669
718
  request.method !== 'HEAD')
670
719
  ) {
671
- return Response.json(
672
- { error: 'InvalidRequest', message: 'Method not allowed' },
673
- { status: 405 },
674
- )
720
+ return invalidRequestResponse('Method not allowed', 405)
675
721
  }
676
722
 
677
723
  try {
@@ -719,7 +765,7 @@ export class LexRouter {
719
765
  headers,
720
766
  })
721
767
  } catch (error) {
722
- return this.handleError(request, method, error)
768
+ return this.handlerError(request, method, error)
723
769
  }
724
770
  }
725
771
  }
@@ -730,7 +776,7 @@ export class LexRouter {
730
776
  auth?: LexRouterAuth<Credentials, Method>,
731
777
  ): FetchHandler {
732
778
  const {
733
- onHandlerError,
779
+ onSocketError,
734
780
  upgradeWebSocket = (globalThis as any).Deno?.upgradeWebSocket as
735
781
  | UpgradeWebSocket
736
782
  | undefined,
@@ -743,36 +789,25 @@ export class LexRouter {
743
789
 
744
790
  return async (request, connection) => {
745
791
  if (request.method !== 'GET') {
746
- return Response.json(
747
- { error: 'InvalidRequest', message: 'Method not allowed' },
748
- { status: 405 },
749
- )
792
+ return invalidRequestResponse('Method not allowed', 405)
750
793
  }
751
794
 
752
795
  if (
753
796
  request.headers.get('connection')?.toLowerCase() !== 'upgrade' ||
754
797
  request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
755
798
  ) {
756
- return Response.json(
757
- {
758
- error: 'InvalidRequest',
759
- message: 'XRPC subscriptions are only available over WebSocket',
760
- },
799
+ return invalidRequestResponse(
800
+ 'XRPC subscriptions are only available over WebSocket',
801
+ 426,
761
802
  {
762
- status: 426,
763
- headers: {
764
- Connection: 'Upgrade',
765
- Upgrade: 'websocket',
766
- },
803
+ Connection: 'Upgrade',
804
+ Upgrade: 'websocket',
767
805
  },
768
806
  )
769
807
  }
770
808
 
771
809
  if (request.signal.aborted) {
772
- return Response.json(
773
- { error: 'RequestAborted', message: 'The request was aborted' },
774
- { status: 499 },
775
- )
810
+ return invalidRequestResponse('Request aborted', 499)
776
811
  }
777
812
 
778
813
  try {
@@ -785,16 +820,6 @@ export class LexRouter {
785
820
  const { signal } = abortController
786
821
  const abort = () => abortController.abort()
787
822
 
788
- const onMessage = (event: unknown) => {
789
- const error = new LexError(
790
- 'InvalidRequest',
791
- 'XRPC subscriptions do not accept messages',
792
- { cause: event },
793
- )
794
- socket.send(encodeErrorFrame(error))
795
- socket.close(1008, error.error)
796
- }
797
-
798
823
  const onOpen = async () => {
799
824
  try {
800
825
  const url = new URL(request.url)
@@ -819,10 +844,30 @@ export class LexRouter {
819
844
 
820
845
  const iterator = iterable[Symbol.asyncIterator]()
821
846
 
822
- signal.addEventListener('abort', async () => {
823
- // @NOTE will cause the process to crash if this throws
824
- await iterator.return?.()
825
- })
847
+ if (iterator.return) {
848
+ signal.addEventListener(
849
+ 'abort',
850
+ () => {
851
+ // @NOTE if iterator.return() throws, and no onSocketError is
852
+ // provided, or if onSocketError itself throws, the error will
853
+ // be unhandled, causing the process to crash. This is
854
+ // intentional, as it surfaces critical errors that occur
855
+ // during cleanup of the subscription.
856
+
857
+ void new Promise((resolve) => {
858
+ // Wrapping in new Promise to catch any potential sync errors thrown by iterator.return()
859
+ resolve(iterator.return!())
860
+ }).catch(
861
+ onSocketError
862
+ ? (error) => onSocketError({ request, method, error })
863
+ : null,
864
+ )
865
+ },
866
+ {
867
+ once: true,
868
+ },
869
+ )
870
+ }
826
871
 
827
872
  while (!signal.aborted && socket.readyState === 1) {
828
873
  const result = await iterator.next()
@@ -845,23 +890,27 @@ export class LexRouter {
845
890
  } catch (error) {
846
891
  // If the socket is still open, send an error frame before closing
847
892
  if (socket.readyState === 1) {
848
- const lexError =
849
- error instanceof LexError
850
- ? error
851
- : new LexError('InternalError', 'An internal error occurred')
852
-
853
- socket.send(encodeErrorFrame(lexError))
854
-
855
- socket.close(
856
- // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
857
- error instanceof LexError ? 1008 : 1011,
858
- lexError.error,
859
- )
893
+ const isLexError = error instanceof LexError
894
+
895
+ // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
896
+ const code =
897
+ isLexError && method.errors?.includes(error.error)
898
+ ? 1008 // Policy Violation for known LexErrors
899
+ : 1011 // Internal Error for unexpected errors
900
+
901
+ if (isLexError) {
902
+ socket.send(encodeErrorFrame(error.toJSON()))
903
+ socket.close(code, error.error)
904
+ } else {
905
+ const error = 'InternalServerError'
906
+ const message = 'An internal error occurred'
907
+ socket.send(encodeErrorFrame({ error, message }))
908
+ socket.close(code, error)
909
+ }
860
910
  }
861
911
 
862
- // Only report unexpected processing errors
863
- if (onHandlerError && !isAbortReason(request.signal, error)) {
864
- await onHandlerError({ error, request, method })
912
+ if (onSocketError && !isAbortReason(signal, error)) {
913
+ await onSocketError({ request, method, error })
865
914
  }
866
915
  } finally {
867
916
  abortController.abort()
@@ -875,30 +924,27 @@ export class LexRouter {
875
924
 
876
925
  return response
877
926
  } catch (error) {
878
- return this.handleError(request, method, error)
927
+ return this.handlerError(request, method, error)
879
928
  }
880
929
  }
881
930
  }
882
931
 
883
- private async handleError(
932
+ private async handlerError(
884
933
  request: Request,
885
934
  method: LexMethod,
886
- error: unknown,
935
+ cause: unknown,
887
936
  ) {
888
937
  // Only report unexpected processing errors
889
- const { onHandlerError } = this.options
890
- if (onHandlerError && !isAbortReason(request.signal, error)) {
891
- await onHandlerError({ error, request, method })
938
+ if (isAbortReason(request.signal, cause)) {
939
+ return Response.json({ error: 'RequestAborted' }, { status: 499 })
892
940
  }
893
941
 
894
- if (error instanceof LexError) {
895
- return error.toResponse()
896
- }
942
+ const error = LexServerError.from(cause)
897
943
 
898
- return Response.json(
899
- { error: 'InternalError', message: 'An internal error occurred' },
900
- { status: 500 },
901
- )
944
+ const { onHandlerError } = this.options
945
+ if (onHandlerError) await onHandlerError({ error, request, method })
946
+
947
+ return error.toResponse()
902
948
  }
903
949
 
904
950
  /**
@@ -934,21 +980,58 @@ export class LexRouter {
934
980
  request: Request,
935
981
  connection?: ConnectionInfo,
936
982
  ): Promise<Response> => {
937
- const nsid = extractMethodNsid(request)
938
-
939
- const fetch = nsid
940
- ? (this.handlers as Map<unknown, FetchHandler>).get(nsid)
941
- : undefined
942
- if (fetch) return fetch(request, connection)
943
-
944
- if (!nsid || !isNsidString(nsid)) {
945
- return Response.json(
946
- {
947
- error: 'InvalidRequest',
948
- message: 'Invalid XRPC method path',
949
- },
950
- { status: 404 },
951
- )
983
+ const { pathname } = new URL(request.url)
984
+ const atprotoProxy = request.headers.get('atproto-proxy')
985
+
986
+ if (!pathname.startsWith(XRPC_PATH_PREFIX)) {
987
+ // Handle non XRPC paths
988
+ const { fallback } = this.options
989
+ if (fallback) return fallback(request, connection)
990
+ return new Response('Not Found', { status: 404 })
991
+ }
992
+
993
+ if (pathname === XRPC_HEALTH_CHECK_PATH) {
994
+ if (request.method !== 'GET') {
995
+ return invalidRequestResponse('Method not allowed', 405)
996
+ }
997
+ if (atprotoProxy != null) {
998
+ return invalidRequestResponse(
999
+ 'atproto-proxy header is not allowed on health check endpoint',
1000
+ )
1001
+ }
1002
+
1003
+ const { healthCheck } = this.options
1004
+ const data = healthCheck ? await healthCheck(request) : { status: 'ok' }
1005
+
1006
+ return Response.json(data)
1007
+ }
1008
+
1009
+ const subPath = pathname.slice(XRPC_PATH_PREFIX.length)
1010
+
1011
+ if (!isNsidString(subPath)) {
1012
+ return invalidRequestResponse('Invalid NSID in URL path')
1013
+ }
1014
+
1015
+ const nsid = normalizeNsid(subPath)
1016
+ if (atprotoProxy == null) {
1017
+ const handler = this.handlers.get(nsid)
1018
+ if (handler) return handler(request, connection)
1019
+ } else {
1020
+ // Handle service proxying logic.
1021
+
1022
+ const proxyInfo = parseAtprotoProxyHeader(atprotoProxy)
1023
+ if (!proxyInfo) {
1024
+ return invalidRequestResponse(
1025
+ `Invalid atproto-proxy header value: ${atprotoProxy}`,
1026
+ )
1027
+ }
1028
+
1029
+ // @TODO actually implement service proxying logic here. The reason it was
1030
+ // not done already is because we want to perform all the heavy lifting
1031
+ // here, while still allowing the possibility to override the endpoint
1032
+ // resolution, etc.
1033
+
1034
+ // @NOTE see ./service-auth.ts for potential common code (did resolver, etc.)
952
1035
  }
953
1036
 
954
1037
  return Response.json(
@@ -961,15 +1044,6 @@ export class LexRouter {
961
1044
  }
962
1045
  }
963
1046
 
964
- function extractMethodNsid(request: Request): string | null {
965
- const { pathname } = new URL(request.url)
966
- if (!pathname.startsWith('/xrpc/')) return null
967
- if (pathname.includes('/', 6)) return null
968
- // We don't really need to validate the NSID here, the existence of the route
969
- // (which is looked up based on an NSID) is sufficient.
970
- return pathname.slice(6)
971
- }
972
-
973
1047
  async function getProcedureInput<M extends Procedure>(
974
1048
  this: M,
975
1049
  request: Request,
@@ -989,7 +1063,10 @@ async function getProcedureInput<M extends Procedure>(
989
1063
  : undefined)
990
1064
 
991
1065
  if (!this.input.matchesEncoding(encoding)) {
992
- throw new LexError('InvalidRequest', `Invalid content-type: ${encoding}`)
1066
+ throw new LexServerError(400, {
1067
+ error: 'InvalidRequest',
1068
+ message: `Invalid content-type: ${encoding}`,
1069
+ })
993
1070
  }
994
1071
 
995
1072
  if (this.input.encoding === 'application/json') {
@@ -1014,17 +1091,31 @@ async function getQueryInput<M extends Query>(
1014
1091
  request.headers.has('content-type') ||
1015
1092
  request.headers.has('content-length')
1016
1093
  ) {
1017
- throw new LexError('InvalidRequest', 'GET requests must not have a body')
1094
+ throw new LexServerError(400, {
1095
+ error: 'InvalidRequest',
1096
+ message: 'GET requests must not have a body',
1097
+ })
1018
1098
  }
1019
1099
 
1020
1100
  return undefined as InferMethodInput<M, Body>
1021
1101
  }
1022
1102
 
1103
+ function onMessage(this: WebSocket, _event: unknown) {
1104
+ const error = 'InvalidRequest'
1105
+ const message = 'XRPC subscriptions do not accept messages'
1106
+ this.send(encodeErrorFrame({ error, message }))
1107
+ // 1003 indicates that an endpoint is terminating the connection
1108
+ // because it has received a type of data it cannot accept (e.g., an
1109
+ // endpoint that understands only text data MAY send this if it
1110
+ // receives a binary message).
1111
+ this.close(1003, error)
1112
+ }
1113
+
1023
1114
  // Pre-encoded frame header for error frames
1024
1115
  const ERROR_FRAME_HEADER = /*#__PURE__*/ encode({ op: -1 })
1025
1116
 
1026
- function encodeErrorFrame(error: LexError): Uint8Array {
1027
- return ui8Concat([ERROR_FRAME_HEADER, encode(error.toJSON())])
1117
+ function encodeErrorFrame(errorData: LexErrorData): Uint8Array {
1118
+ return ui8Concat([ERROR_FRAME_HEADER, encode(errorData)])
1028
1119
  }
1029
1120
 
1030
1121
  // Pre-encoded frame header for message frames with unknown type
@@ -1052,9 +1143,74 @@ function encodeMessageFrame(method: Subscription, value: LexValue): Uint8Array {
1052
1143
  }
1053
1144
 
1054
1145
  function isAbortReason(signal: AbortSignal, error: unknown): boolean {
1055
- if (!signal.aborted || signal.reason == null) return false
1056
1146
  return (
1057
- error === signal.reason ||
1058
- (error instanceof Error && error.cause === signal.reason)
1147
+ signal.aborted &&
1148
+ signal.reason != null &&
1149
+ error instanceof Error &&
1150
+ (error === signal.reason || error.cause === signal.reason)
1151
+ )
1152
+ }
1153
+
1154
+ export type ServiceProxyInfo = {
1155
+ did: DidString
1156
+ serviceId: string
1157
+ }
1158
+
1159
+ function parseAtprotoProxyHeader(value: string): ServiceProxyInfo | null {
1160
+ // /!\ Hot path
1161
+
1162
+ // (fast) sanity check to avoid unnecessary parsing for non-DID values
1163
+ if (!value.startsWith('did:')) return null
1164
+
1165
+ // The format is expected to be `did:example:service#serviceId`
1166
+ const hashIndex = value.indexOf('#')
1167
+ if (hashIndex === -1) return null
1168
+
1169
+ const fragmentIndex = hashIndex + 1
1170
+ // Basic validation if the fragment
1171
+ if (fragmentIndex === value.length) return null
1172
+ if (value.includes('#', fragmentIndex)) return null
1173
+ if (value.includes(' ', fragmentIndex)) return null
1174
+
1175
+ const did = value.slice(0, hashIndex)
1176
+ if (!isDidString(did)) return null
1177
+
1178
+ const serviceId = value.slice(fragmentIndex)
1179
+ return { did, serviceId }
1180
+ }
1181
+
1182
+ function normalizeNsid(nsid: NsidString): NsidString {
1183
+ const lastDotIdx = nsid.lastIndexOf('.')
1184
+
1185
+ // The domain name part of the NSID is case-insensitive, but the last part is
1186
+ // case-sensitive. Normalize the domain part to lowercase.
1187
+ if (lastDotIdx !== -1 && hasUpperCase(nsid, 0, lastDotIdx)) {
1188
+ return `${nsid.slice(0, lastDotIdx).toLowerCase()}.${nsid.slice(lastDotIdx + 1)}` as NsidString
1189
+ }
1190
+
1191
+ return nsid
1192
+ }
1193
+
1194
+ function hasUpperCase(str: string, start = 0, end = str.length): boolean {
1195
+ for (let i = start; i < end; i++) {
1196
+ const code = str.charCodeAt(i)
1197
+ if (code >= 0x41 && code <= 0x5a) {
1198
+ return true
1199
+ }
1200
+ }
1201
+ return false
1202
+ }
1203
+
1204
+ function invalidRequestResponse(
1205
+ message: string,
1206
+ status = 400,
1207
+ headers?: HeadersInit,
1208
+ ): Response {
1209
+ return Response.json(
1210
+ {
1211
+ error: 'InvalidRequest',
1212
+ message,
1213
+ },
1214
+ { status, headers },
1059
1215
  )
1060
1216
  }
@@ -1,5 +1,12 @@
1
1
  import { abortableSleep } from './sleep.js'
2
2
 
3
+ /**
4
+ * Performs polling based backpressure management for a WebSocket connection. If
5
+ * the amount of buffered data exceeds the specified high water mark, this
6
+ * function will wait until the buffered amount drops below the low water mark
7
+ * before resolving. This is useful for preventing memory issues when sending
8
+ * large amounts of data over a WebSocket connection.
9
+ */
3
10
  export async function drainWebsocket(
4
11
  socket: WebSocket,
5
12
  signal: AbortSignal,
@@ -12,6 +19,10 @@ export async function drainWebsocket(
12
19
  } = {},
13
20
  ): Promise<void> {
14
21
  if (socket.bufferedAmount > highWaterMark) {
22
+ // Once we exceed the high water mark, we wait until the buffered amount
23
+ // drops below the low water mark before allowing more data to be sent. This
24
+ // creates a hysteresis effect that prevents rapid toggling around the
25
+ // threshold.
15
26
  while (
16
27
  socket.readyState === 1 &&
17
28
  socket.bufferedAmount !== 0 &&