@atproto/xrpc-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.
@@ -0,0 +1,2 @@
1
+ export declare const logger: import("pino").default.Logger<import("pino").default.LoggerOptions>;
2
+ export default logger;
@@ -0,0 +1,19 @@
1
+ import express, { NextFunction, RequestHandler } from 'express';
2
+ import { Lexicons, LexXrpcProcedure, LexXrpcQuery } from '@atproto/lexicon';
3
+ import { XRPCHandler, XRPCHandlerConfig, Options } from './types';
4
+ export declare function createServer(lexicons?: unknown[], options?: Options): Server;
5
+ export declare class Server {
6
+ router: import("express-serve-static-core").Router;
7
+ routes: import("express-serve-static-core").Router;
8
+ lex: Lexicons;
9
+ options: Options;
10
+ middleware: Record<'json' | 'text', RequestHandler>;
11
+ constructor(lexicons?: unknown[], opts?: Options);
12
+ method(nsid: string, configOrFn: XRPCHandlerConfig | XRPCHandler): void;
13
+ addMethod(nsid: string, configOrFn: XRPCHandlerConfig | XRPCHandler): void;
14
+ addLexicon(doc: unknown): void;
15
+ addLexicons(docs: unknown[]): void;
16
+ protected addRoute(nsid: string, def: LexXrpcQuery | LexXrpcProcedure, config: XRPCHandlerConfig): Promise<void>;
17
+ catchall(req: express.Request, _res: express.Response, next: NextFunction): Promise<void>;
18
+ createHandler(nsid: string, def: LexXrpcQuery | LexXrpcProcedure, handler: XRPCHandler): RequestHandler;
19
+ }
@@ -0,0 +1,115 @@
1
+ import express from 'express';
2
+ import zod from 'zod';
3
+ import { ResponseType } from '@atproto/xrpc';
4
+ export declare type Options = {
5
+ validateResponse?: boolean;
6
+ payload?: {
7
+ jsonLimit?: number;
8
+ blobLimit?: number;
9
+ textLimit?: number;
10
+ };
11
+ };
12
+ export declare type UndecodedParams = typeof express.request['query'];
13
+ export declare type Primitive = string | number | boolean;
14
+ export declare type Params = Record<string, Primitive | Primitive[] | undefined>;
15
+ export declare const handlerInput: zod.ZodObject<{
16
+ encoding: zod.ZodString;
17
+ body: zod.ZodAny;
18
+ }, "strip", zod.ZodTypeAny, {
19
+ body?: any;
20
+ encoding: string;
21
+ }, {
22
+ body?: any;
23
+ encoding: string;
24
+ }>;
25
+ export declare type HandlerInput = zod.infer<typeof handlerInput>;
26
+ export declare const handlerAuth: zod.ZodObject<{
27
+ credentials: zod.ZodAny;
28
+ artifacts: zod.ZodAny;
29
+ }, "strip", zod.ZodTypeAny, {
30
+ credentials?: any;
31
+ artifacts?: any;
32
+ }, {
33
+ credentials?: any;
34
+ artifacts?: any;
35
+ }>;
36
+ export declare type HandlerAuth = zod.infer<typeof handlerAuth>;
37
+ export declare const handlerSuccess: zod.ZodObject<{
38
+ encoding: zod.ZodString;
39
+ body: zod.ZodAny;
40
+ }, "strip", zod.ZodTypeAny, {
41
+ body?: any;
42
+ encoding: string;
43
+ }, {
44
+ body?: any;
45
+ encoding: string;
46
+ }>;
47
+ export declare type HandlerSuccess = zod.infer<typeof handlerSuccess>;
48
+ export declare const handlerError: zod.ZodObject<{
49
+ status: zod.ZodNumber;
50
+ error: zod.ZodOptional<zod.ZodString>;
51
+ message: zod.ZodOptional<zod.ZodString>;
52
+ }, "strip", zod.ZodTypeAny, {
53
+ error?: string | undefined;
54
+ message?: string | undefined;
55
+ status: number;
56
+ }, {
57
+ error?: string | undefined;
58
+ message?: string | undefined;
59
+ status: number;
60
+ }>;
61
+ export declare type HandlerError = zod.infer<typeof handlerError>;
62
+ export declare type HandlerOutput = HandlerSuccess | HandlerError;
63
+ export declare type XRPCHandler = (ctx: {
64
+ auth: HandlerAuth | undefined;
65
+ params: Params;
66
+ input: HandlerInput | undefined;
67
+ req: express.Request;
68
+ res: express.Response;
69
+ }) => Promise<HandlerOutput> | HandlerOutput | undefined;
70
+ export declare type AuthOutput = HandlerAuth | HandlerError;
71
+ export declare type AuthVerifier = (ctx: {
72
+ req: express.Request;
73
+ res: express.Response;
74
+ }) => Promise<AuthOutput> | AuthOutput;
75
+ export declare type XRPCHandlerConfig = {
76
+ auth?: AuthVerifier;
77
+ handler: XRPCHandler;
78
+ };
79
+ export declare class XRPCError extends Error {
80
+ type: ResponseType;
81
+ errorMessage?: string | undefined;
82
+ customErrorName?: string | undefined;
83
+ constructor(type: ResponseType, errorMessage?: string | undefined, customErrorName?: string | undefined);
84
+ get payload(): {
85
+ error: string | undefined;
86
+ message: string | undefined;
87
+ };
88
+ get typeStr(): string | undefined;
89
+ static fromError(error: unknown): XRPCError;
90
+ }
91
+ export declare function isHandlerError(v: unknown): v is HandlerError;
92
+ export declare class InvalidRequestError extends XRPCError {
93
+ constructor(errorMessage?: string, customErrorName?: string);
94
+ }
95
+ export declare class AuthRequiredError extends XRPCError {
96
+ constructor(errorMessage?: string, customErrorName?: string);
97
+ }
98
+ export declare class ForbiddenError extends XRPCError {
99
+ constructor(errorMessage?: string, customErrorName?: string);
100
+ }
101
+ export declare class InternalServerError extends XRPCError {
102
+ constructor(errorMessage?: string, customErrorName?: string);
103
+ }
104
+ export declare class UpstreamFailureError extends XRPCError {
105
+ constructor(errorMessage?: string, customErrorName?: string);
106
+ }
107
+ export declare class NotEnoughResoucesError extends XRPCError {
108
+ constructor(errorMessage?: string, customErrorName?: string);
109
+ }
110
+ export declare class UpstreamTimeoutError extends XRPCError {
111
+ constructor(errorMessage?: string, customErrorName?: string);
112
+ }
113
+ export declare class MethodNotImplementedError extends XRPCError {
114
+ constructor(errorMessage?: string, customErrorName?: string);
115
+ }
package/dist/util.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import express from 'express';
2
+ import { Lexicons, LexXrpcProcedure, LexXrpcQuery } from '@atproto/lexicon';
3
+ import { UndecodedParams, Params, HandlerInput, HandlerSuccess, Options } from './types';
4
+ export declare function decodeQueryParams(def: LexXrpcProcedure | LexXrpcQuery, params: UndecodedParams): Params;
5
+ export declare function decodeQueryParam(type: string, value: unknown): string | number | boolean | undefined;
6
+ export declare function validateInput(nsid: string, def: LexXrpcProcedure | LexXrpcQuery, req: express.Request, opts: Options, lexicons: Lexicons): HandlerInput | undefined;
7
+ export declare function validateOutput(nsid: string, def: LexXrpcProcedure | LexXrpcQuery, output: HandlerSuccess | undefined, lexicons: Lexicons): HandlerSuccess | undefined;
8
+ export declare function normalizeMime(v: string): any;
9
+ export declare function hasBody(req: express.Request): string | true | undefined;
10
+ export declare function processBodyAsBytes(req: express.Request): Promise<Uint8Array>;
package/jest.config.js ADDED
@@ -0,0 +1,6 @@
1
+ const base = require('../../jest.config.base.js')
2
+
3
+ module.exports = {
4
+ ...base,
5
+ displayName: 'XRPC Server',
6
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@atproto/xrpc-server",
3
+ "version": "0.0.1",
4
+ "main": "dist/index.js",
5
+ "scripts": {
6
+ "test": "jest",
7
+ "prettier": "prettier --check src/",
8
+ "prettier:fix": "prettier --write src/",
9
+ "lint": "eslint . --ext .ts,.tsx",
10
+ "lint:fix": "yarn lint --fix",
11
+ "verify": "run-p prettier lint",
12
+ "verify:fix": "yarn prettier:fix && yarn lint:fix",
13
+ "build": "node ./build.js",
14
+ "postbuild": "tsc --build tsconfig.build.json",
15
+ "update-main-to-dist": "node ./update-pkg.js --update-main-to-dist",
16
+ "update-main-to-src": "node ./update-pkg.js --update-main-to-src",
17
+ "prepublish": "npm run update-main-to-dist",
18
+ "postpublish": "npm run update-main-to-src"
19
+ },
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "@atproto/common": "*",
23
+ "@atproto/lexicon": "*",
24
+ "express": "^4.17.2",
25
+ "http-errors": "^2.0.0",
26
+ "mime-types": "^2.1.35",
27
+ "zod": "^3.14.2"
28
+ },
29
+ "devDependencies": {
30
+ "@atproto/crypto": "*",
31
+ "@atproto/xrpc": "*",
32
+ "@types/express": "^4.17.13",
33
+ "@types/http-errors": "^2.0.1"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './types'
2
+ export * from './server'
package/src/logger.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { subsystemLogger } from '@atproto/common'
2
+
3
+ export const logger = subsystemLogger('xrpc-server')
4
+
5
+ export default logger
package/src/server.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { Readable } from 'stream'
2
+ import express, {
3
+ ErrorRequestHandler,
4
+ NextFunction,
5
+ RequestHandler,
6
+ } from 'express'
7
+ import { Lexicons, LexXrpcProcedure, LexXrpcQuery } from '@atproto/lexicon'
8
+ import {
9
+ XRPCHandler,
10
+ XRPCError,
11
+ InvalidRequestError,
12
+ HandlerOutput,
13
+ HandlerSuccess,
14
+ handlerSuccess,
15
+ XRPCHandlerConfig,
16
+ MethodNotImplementedError,
17
+ HandlerAuth,
18
+ AuthVerifier,
19
+ isHandlerError,
20
+ Options,
21
+ } from './types'
22
+ import { decodeQueryParams, validateInput, validateOutput } from './util'
23
+ import log from './logger'
24
+
25
+ export function createServer(lexicons?: unknown[], options?: Options) {
26
+ return new Server(lexicons, options)
27
+ }
28
+
29
+ export class Server {
30
+ router = express.Router()
31
+ routes = express.Router()
32
+ lex = new Lexicons()
33
+ options: Options
34
+ middleware: Record<'json' | 'text', RequestHandler>
35
+
36
+ constructor(lexicons?: unknown[], opts?: Options) {
37
+ if (lexicons) {
38
+ this.addLexicons(lexicons)
39
+ }
40
+ this.router.use(this.routes)
41
+ this.router.use('/xrpc/:methodId', this.catchall.bind(this))
42
+ this.router.use(errorMiddleware)
43
+ this.options = opts ?? {}
44
+ this.middleware = {
45
+ json: express.json({ limit: opts?.payload?.jsonLimit }),
46
+ text: express.text({ limit: opts?.payload?.textLimit }),
47
+ }
48
+ }
49
+
50
+ // handlers
51
+ // =
52
+
53
+ method(nsid: string, configOrFn: XRPCHandlerConfig | XRPCHandler) {
54
+ this.addMethod(nsid, configOrFn)
55
+ }
56
+
57
+ addMethod(nsid: string, configOrFn: XRPCHandlerConfig | XRPCHandler) {
58
+ const config =
59
+ typeof configOrFn === 'function' ? { handler: configOrFn } : configOrFn
60
+ const def = this.lex.getDef(nsid)
61
+ if (!def || (def.type !== 'query' && def.type !== 'procedure')) {
62
+ throw new Error(`Lex def for ${nsid} is not a query or a procedure`)
63
+ }
64
+ this.addRoute(nsid, def, config)
65
+ }
66
+
67
+ // schemas
68
+ // =
69
+
70
+ addLexicon(doc: unknown) {
71
+ this.lex.add(doc)
72
+ }
73
+
74
+ addLexicons(docs: unknown[]) {
75
+ for (const doc of docs) {
76
+ this.addLexicon(doc)
77
+ }
78
+ }
79
+
80
+ // http
81
+ // =
82
+
83
+ protected async addRoute(
84
+ nsid: string,
85
+ def: LexXrpcQuery | LexXrpcProcedure,
86
+ config: XRPCHandlerConfig,
87
+ ) {
88
+ const verb: 'post' | 'get' = def.type === 'procedure' ? 'post' : 'get'
89
+ const middleware: RequestHandler[] = []
90
+ middleware.push(createLocalsMiddleware(nsid))
91
+ if (config.auth) {
92
+ middleware.push(createAuthMiddleware(config.auth))
93
+ }
94
+ if (verb === 'post') {
95
+ middleware.push(this.middleware.json)
96
+ middleware.push(this.middleware.text)
97
+ }
98
+ this.routes[verb](
99
+ `/xrpc/${nsid}`,
100
+ ...middleware,
101
+ this.createHandler(nsid, def, config.handler),
102
+ )
103
+ }
104
+
105
+ async catchall(
106
+ req: express.Request,
107
+ _res: express.Response,
108
+ next: NextFunction,
109
+ ) {
110
+ const def = this.lex.getDef(req.params.methodId)
111
+ if (!def) {
112
+ return next(new MethodNotImplementedError())
113
+ }
114
+ // validate method
115
+ if (def.type === 'query' && req.method !== 'GET') {
116
+ return next(
117
+ new InvalidRequestError(
118
+ `Incorrect HTTP method (${req.method}) expected GET`,
119
+ ),
120
+ )
121
+ } else if (def.type === 'procedure' && req.method !== 'POST') {
122
+ return next(
123
+ new InvalidRequestError(
124
+ `Incorrect HTTP method (${req.method}) expected POST`,
125
+ ),
126
+ )
127
+ }
128
+ return next()
129
+ }
130
+
131
+ createHandler(
132
+ nsid: string,
133
+ def: LexXrpcQuery | LexXrpcProcedure,
134
+ handler: XRPCHandler,
135
+ ): RequestHandler {
136
+ const validateReqInput = (req: express.Request) =>
137
+ validateInput(nsid, def, req, this.options, this.lex)
138
+ const validateResOutput =
139
+ this.options.validateResponse === false
140
+ ? (output?: HandlerSuccess) => output
141
+ : (output?: HandlerSuccess) =>
142
+ validateOutput(nsid, def, output, this.lex)
143
+ const assertValidXrpcParams = (params: unknown) =>
144
+ this.lex.assertValidXrpcParams(nsid, params)
145
+ return async function (req, res, next) {
146
+ try {
147
+ // validate request
148
+ const params = decodeQueryParams(def, req.query)
149
+ try {
150
+ assertValidXrpcParams(params)
151
+ } catch (e) {
152
+ throw new InvalidRequestError(String(e))
153
+ }
154
+ const input = validateReqInput(req)
155
+
156
+ if (input?.body instanceof Readable) {
157
+ // If the body stream errors at any time, abort the request
158
+ input.body.once('error', next)
159
+ }
160
+
161
+ const locals: RequestLocals = req[kRequestLocals]
162
+
163
+ // run the handler
164
+ const outputUnvalidated = await handler({
165
+ params,
166
+ input,
167
+ auth: locals.auth,
168
+ req,
169
+ res,
170
+ })
171
+
172
+ if (isHandlerError(outputUnvalidated)) {
173
+ throw XRPCError.fromError(outputUnvalidated)
174
+ }
175
+
176
+ if (!outputUnvalidated || isHandlerSuccess(outputUnvalidated)) {
177
+ // validate response
178
+ const output = validateResOutput(outputUnvalidated)
179
+ // send response
180
+ if (
181
+ output?.encoding === 'application/json' ||
182
+ output?.encoding === 'json'
183
+ ) {
184
+ res.status(200).json(output.body)
185
+ } else if (output) {
186
+ res.header('Content-Type', output.encoding)
187
+ res
188
+ .status(200)
189
+ .send(
190
+ output.body instanceof Uint8Array
191
+ ? Buffer.from(output.body)
192
+ : output.body,
193
+ )
194
+ } else {
195
+ res.status(200).end()
196
+ }
197
+ }
198
+ } catch (err: unknown) {
199
+ next(err)
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ function isHandlerSuccess(v: HandlerOutput): v is HandlerSuccess {
206
+ return handlerSuccess.safeParse(v).success
207
+ }
208
+
209
+ const kRequestLocals = Symbol('requestLocals')
210
+
211
+ function createLocalsMiddleware(nsid: string): RequestHandler {
212
+ return function (req, _res, next) {
213
+ const locals: RequestLocals = { auth: undefined, nsid }
214
+ req[kRequestLocals] = locals
215
+ return next()
216
+ }
217
+ }
218
+
219
+ type RequestLocals = {
220
+ auth: HandlerAuth | undefined
221
+ nsid: string
222
+ }
223
+
224
+ function createAuthMiddleware(verifier: AuthVerifier): RequestHandler {
225
+ return async function (req, res, next) {
226
+ try {
227
+ const result = await verifier({ req, res })
228
+ if (isHandlerError(result)) {
229
+ throw XRPCError.fromError(result)
230
+ }
231
+ const locals: RequestLocals = req[kRequestLocals]
232
+ locals.auth = result
233
+ next()
234
+ } catch (err: unknown) {
235
+ next(err)
236
+ }
237
+ }
238
+ }
239
+
240
+ const errorMiddleware: ErrorRequestHandler = function (err, req, res, next) {
241
+ const locals: RequestLocals | undefined = req[kRequestLocals]
242
+ const methodSuffix = locals ? ` method ${locals.nsid}` : ''
243
+ if (err instanceof XRPCError) {
244
+ log.error(err, `error in xrpc${methodSuffix}`)
245
+ } else {
246
+ log.error(err, `unhandled exception in xrpc${methodSuffix}`)
247
+ }
248
+ if (res.headersSent) {
249
+ return next(err)
250
+ }
251
+ const xrpcError = XRPCError.fromError(err)
252
+ return res.status(xrpcError.type).json(xrpcError.payload)
253
+ }
package/src/types.ts ADDED
@@ -0,0 +1,159 @@
1
+ import express from 'express'
2
+ import { isHttpError } from 'http-errors'
3
+ import zod from 'zod'
4
+ import { ResponseType, ResponseTypeStrings } from '@atproto/xrpc'
5
+
6
+ export type Options = {
7
+ validateResponse?: boolean
8
+ payload?: {
9
+ jsonLimit?: number
10
+ blobLimit?: number
11
+ textLimit?: number
12
+ }
13
+ }
14
+
15
+ export type UndecodedParams = typeof express.request['query']
16
+
17
+ export type Primitive = string | number | boolean
18
+ export type Params = Record<string, Primitive | Primitive[] | undefined>
19
+
20
+ export const handlerInput = zod.object({
21
+ encoding: zod.string(),
22
+ body: zod.any(),
23
+ })
24
+ export type HandlerInput = zod.infer<typeof handlerInput>
25
+
26
+ export const handlerAuth = zod.object({
27
+ credentials: zod.any(),
28
+ artifacts: zod.any(),
29
+ })
30
+ export type HandlerAuth = zod.infer<typeof handlerAuth>
31
+
32
+ export const handlerSuccess = zod.object({
33
+ encoding: zod.string(),
34
+ body: zod.any(),
35
+ })
36
+ export type HandlerSuccess = zod.infer<typeof handlerSuccess>
37
+
38
+ export const handlerError = zod.object({
39
+ status: zod.number(),
40
+ error: zod.string().optional(),
41
+ message: zod.string().optional(),
42
+ })
43
+ export type HandlerError = zod.infer<typeof handlerError>
44
+
45
+ export type HandlerOutput = HandlerSuccess | HandlerError
46
+
47
+ export type XRPCHandler = (ctx: {
48
+ auth: HandlerAuth | undefined
49
+ params: Params
50
+ input: HandlerInput | undefined
51
+ req: express.Request
52
+ res: express.Response
53
+ }) => Promise<HandlerOutput> | HandlerOutput | undefined
54
+
55
+ export type AuthOutput = HandlerAuth | HandlerError
56
+
57
+ export type AuthVerifier = (ctx: {
58
+ req: express.Request
59
+ res: express.Response
60
+ }) => Promise<AuthOutput> | AuthOutput
61
+
62
+ export type XRPCHandlerConfig = {
63
+ auth?: AuthVerifier
64
+ handler: XRPCHandler
65
+ }
66
+
67
+ export class XRPCError extends Error {
68
+ constructor(
69
+ public type: ResponseType,
70
+ public errorMessage?: string,
71
+ public customErrorName?: string,
72
+ ) {
73
+ super(errorMessage)
74
+ }
75
+
76
+ get payload() {
77
+ return {
78
+ error: this.customErrorName,
79
+ message:
80
+ this.type === ResponseType.InternalServerError
81
+ ? this.typeStr // Do not respond with error details for 500s
82
+ : this.errorMessage || this.typeStr,
83
+ }
84
+ }
85
+
86
+ get typeStr(): string | undefined {
87
+ return ResponseTypeStrings[this.type]
88
+ }
89
+
90
+ static fromError(error: unknown) {
91
+ if (error instanceof XRPCError) {
92
+ return error
93
+ }
94
+ let resultErr: XRPCError
95
+ if (isHttpError(error)) {
96
+ resultErr = new XRPCError(error.status, error.message, error.name)
97
+ } else if (isHandlerError(error)) {
98
+ resultErr = new XRPCError(error.status, error.message, error.error)
99
+ } else if (error instanceof Error) {
100
+ resultErr = new InternalServerError(error.message)
101
+ } else {
102
+ resultErr = new InternalServerError('Unexpected internal server error')
103
+ }
104
+ resultErr.cause = error
105
+ return resultErr
106
+ }
107
+ }
108
+
109
+ export function isHandlerError(v: unknown): v is HandlerError {
110
+ return handlerError.safeParse(v).success
111
+ }
112
+
113
+ export class InvalidRequestError extends XRPCError {
114
+ constructor(errorMessage?: string, customErrorName?: string) {
115
+ super(ResponseType.InvalidRequest, errorMessage, customErrorName)
116
+ }
117
+ }
118
+
119
+ export class AuthRequiredError extends XRPCError {
120
+ constructor(errorMessage?: string, customErrorName?: string) {
121
+ super(ResponseType.AuthRequired, errorMessage, customErrorName)
122
+ }
123
+ }
124
+
125
+ export class ForbiddenError extends XRPCError {
126
+ constructor(errorMessage?: string, customErrorName?: string) {
127
+ super(ResponseType.Forbidden, errorMessage, customErrorName)
128
+ }
129
+ }
130
+
131
+ export class InternalServerError extends XRPCError {
132
+ constructor(errorMessage?: string, customErrorName?: string) {
133
+ super(ResponseType.InternalServerError, errorMessage, customErrorName)
134
+ }
135
+ }
136
+
137
+ export class UpstreamFailureError extends XRPCError {
138
+ constructor(errorMessage?: string, customErrorName?: string) {
139
+ super(ResponseType.UpstreamFailure, errorMessage, customErrorName)
140
+ }
141
+ }
142
+
143
+ export class NotEnoughResoucesError extends XRPCError {
144
+ constructor(errorMessage?: string, customErrorName?: string) {
145
+ super(ResponseType.NotEnoughResouces, errorMessage, customErrorName)
146
+ }
147
+ }
148
+
149
+ export class UpstreamTimeoutError extends XRPCError {
150
+ constructor(errorMessage?: string, customErrorName?: string) {
151
+ super(ResponseType.UpstreamTimeout, errorMessage, customErrorName)
152
+ }
153
+ }
154
+
155
+ export class MethodNotImplementedError extends XRPCError {
156
+ constructor(errorMessage?: string, customErrorName?: string) {
157
+ super(ResponseType.MethodNotImplemented, errorMessage, customErrorName)
158
+ }
159
+ }