@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.
- package/CHANGELOG.md +19 -0
- package/README.md +38 -21
- package/dist/errors.d.ts +28 -58
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +72 -72
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/{lex-server.d.ts → lex-router.d.ts} +55 -21
- package/dist/lex-router.d.ts.map +1 -0
- package/dist/{lex-server.js → lex-router.js} +169 -73
- package/dist/lex-router.js.map +1 -0
- package/dist/lib/drain-websocket.d.ts +7 -0
- package/dist/lib/drain-websocket.d.ts.map +1 -1
- package/dist/lib/drain-websocket.js +11 -0
- package/dist/lib/drain-websocket.js.map +1 -1
- package/dist/lib/www-authenticate.d.ts +4 -3
- package/dist/lib/www-authenticate.d.ts.map +1 -1
- package/dist/lib/www-authenticate.js +29 -16
- package/dist/lib/www-authenticate.js.map +1 -1
- package/dist/nodejs.d.ts +1 -1
- package/dist/nodejs.d.ts.map +1 -1
- package/dist/nodejs.js +1 -1
- package/dist/nodejs.js.map +1 -1
- package/dist/service-auth.d.ts +1 -1
- package/dist/service-auth.d.ts.map +1 -1
- package/dist/service-auth.js.map +1 -1
- package/package.json +8 -7
- package/src/errors.test.ts +262 -0
- package/src/errors.ts +103 -78
- package/src/index.ts +1 -7
- package/src/{lex-server.test.ts → lex-router.test.ts} +591 -24
- package/src/{lex-server.ts → lex-router.ts} +275 -119
- package/src/lib/drain-websocket.ts +11 -0
- package/src/lib/www-authenticate.test.ts +134 -0
- package/src/lib/www-authenticate.ts +36 -17
- package/src/nodejs.ts +2 -2
- package/src/service-auth.ts +1 -1
- package/dist/lex-server.d.ts.map +0 -1
- package/dist/lex-server.js.map +0 -1
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { encode } from '@atproto/lex-cbor'
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
438
|
-
*
|
|
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?:
|
|
467
|
+
onHandlerError?: HandlerErrorHook
|
|
441
468
|
/**
|
|
442
|
-
*
|
|
443
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
763
|
-
|
|
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
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
error
|
|
858
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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.
|
|
927
|
+
return this.handlerError(request, method, error)
|
|
879
928
|
}
|
|
880
929
|
}
|
|
881
930
|
}
|
|
882
931
|
|
|
883
|
-
private async
|
|
932
|
+
private async handlerError(
|
|
884
933
|
request: Request,
|
|
885
934
|
method: LexMethod,
|
|
886
|
-
|
|
935
|
+
cause: unknown,
|
|
887
936
|
) {
|
|
888
937
|
// Only report unexpected processing errors
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
895
|
-
return error.toResponse()
|
|
896
|
-
}
|
|
942
|
+
const error = LexServerError.from(cause)
|
|
897
943
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
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
|
|
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(
|
|
1027
|
-
return ui8Concat([ERROR_FRAME_HEADER, encode(
|
|
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
|
-
|
|
1058
|
-
|
|
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 &&
|