@atproto/pds 0.4.59 → 0.4.60

Sign up to get free protection for your applications and to get access to all the features.
Files changed (118) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/api/app/bsky/actor/getProfile.d.ts.map +1 -1
  3. package/dist/api/app/bsky/actor/getProfile.js +2 -9
  4. package/dist/api/app/bsky/actor/getProfile.js.map +1 -1
  5. package/dist/api/app/bsky/actor/getProfiles.d.ts.map +1 -1
  6. package/dist/api/app/bsky/actor/getProfiles.js +2 -6
  7. package/dist/api/app/bsky/actor/getProfiles.js.map +1 -1
  8. package/dist/api/app/bsky/feed/getActorLikes.d.ts.map +1 -1
  9. package/dist/api/app/bsky/feed/getActorLikes.js +2 -9
  10. package/dist/api/app/bsky/feed/getActorLikes.js.map +1 -1
  11. package/dist/api/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  12. package/dist/api/app/bsky/feed/getAuthorFeed.js +2 -9
  13. package/dist/api/app/bsky/feed/getAuthorFeed.js.map +1 -1
  14. package/dist/api/app/bsky/feed/getFeed.d.ts.map +1 -1
  15. package/dist/api/app/bsky/feed/getFeed.js +2 -1
  16. package/dist/api/app/bsky/feed/getFeed.js.map +1 -1
  17. package/dist/api/app/bsky/feed/getPostThread.d.ts.map +1 -1
  18. package/dist/api/app/bsky/feed/getPostThread.js +12 -14
  19. package/dist/api/app/bsky/feed/getPostThread.js.map +1 -1
  20. package/dist/api/app/bsky/feed/getTimeline.d.ts.map +1 -1
  21. package/dist/api/app/bsky/feed/getTimeline.js +2 -6
  22. package/dist/api/app/bsky/feed/getTimeline.js.map +1 -1
  23. package/dist/api/com/atproto/repo/getRecord.js +1 -1
  24. package/dist/api/com/atproto/repo/getRecord.js.map +1 -1
  25. package/dist/api/com/atproto/server/requestPasswordReset.js +1 -1
  26. package/dist/api/com/atproto/server/requestPasswordReset.js.map +1 -1
  27. package/dist/config/config.d.ts +9 -0
  28. package/dist/config/config.d.ts.map +1 -1
  29. package/dist/config/config.js +10 -1
  30. package/dist/config/config.js.map +1 -1
  31. package/dist/config/env.d.ts +6 -1
  32. package/dist/config/env.d.ts.map +1 -1
  33. package/dist/config/env.js +8 -1
  34. package/dist/config/env.js.map +1 -1
  35. package/dist/context.d.ts +6 -2
  36. package/dist/context.d.ts.map +1 -1
  37. package/dist/context.js +55 -11
  38. package/dist/context.js.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/lexicon/lexicons.d.ts +33 -0
  43. package/dist/lexicon/lexicons.d.ts.map +1 -1
  44. package/dist/lexicon/lexicons.js +42 -3
  45. package/dist/lexicon/lexicons.js.map +1 -1
  46. package/dist/lexicon/types/app/bsky/actor/defs.d.ts +2 -0
  47. package/dist/lexicon/types/app/bsky/actor/defs.d.ts.map +1 -1
  48. package/dist/lexicon/types/app/bsky/actor/defs.js.map +1 -1
  49. package/dist/lexicon/types/app/bsky/actor/profile.d.ts +1 -0
  50. package/dist/lexicon/types/app/bsky/actor/profile.d.ts.map +1 -1
  51. package/dist/lexicon/types/app/bsky/actor/profile.js.map +1 -1
  52. package/dist/lexicon/types/app/bsky/feed/defs.d.ts +13 -2
  53. package/dist/lexicon/types/app/bsky/feed/defs.d.ts.map +1 -1
  54. package/dist/lexicon/types/app/bsky/feed/defs.js +21 -1
  55. package/dist/lexicon/types/app/bsky/feed/defs.js.map +1 -1
  56. package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts +1 -0
  57. package/dist/lexicon/types/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  58. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts +2 -0
  59. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts.map +1 -1
  60. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts +2 -0
  61. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts.map +1 -1
  62. package/dist/mailer/index.d.ts +1 -1
  63. package/dist/mailer/index.d.ts.map +1 -1
  64. package/dist/mailer/index.js.map +1 -1
  65. package/dist/mailer/templates/confirm-email.js +1 -1
  66. package/dist/mailer/templates/confirm-email.js.map +2 -2
  67. package/dist/mailer/templates/delete-account.js +1 -1
  68. package/dist/mailer/templates/delete-account.js.map +2 -2
  69. package/dist/mailer/templates/plc-operation.js +1 -1
  70. package/dist/mailer/templates/plc-operation.js.map +2 -2
  71. package/dist/mailer/templates/reset-password.js +1 -1
  72. package/dist/mailer/templates/reset-password.js.map +2 -2
  73. package/dist/mailer/templates/update-email.js +1 -1
  74. package/dist/mailer/templates/update-email.js.map +2 -2
  75. package/dist/pipethrough.d.ts +26 -26
  76. package/dist/pipethrough.d.ts.map +1 -1
  77. package/dist/pipethrough.js +328 -228
  78. package/dist/pipethrough.js.map +1 -1
  79. package/dist/read-after-write/util.d.ts +13 -5
  80. package/dist/read-after-write/util.d.ts.map +1 -1
  81. package/dist/read-after-write/util.js +37 -22
  82. package/dist/read-after-write/util.js.map +1 -1
  83. package/package.json +16 -15
  84. package/src/api/app/bsky/actor/getProfile.ts +3 -17
  85. package/src/api/app/bsky/actor/getProfiles.ts +3 -15
  86. package/src/api/app/bsky/feed/getActorLikes.ts +3 -19
  87. package/src/api/app/bsky/feed/getAuthorFeed.ts +3 -17
  88. package/src/api/app/bsky/feed/getFeed.ts +3 -1
  89. package/src/api/app/bsky/feed/getPostThread.ts +16 -23
  90. package/src/api/app/bsky/feed/getTimeline.ts +3 -14
  91. package/src/api/com/atproto/repo/getRecord.ts +1 -1
  92. package/src/api/com/atproto/server/requestPasswordReset.ts +1 -1
  93. package/src/config/config.ts +21 -1
  94. package/src/config/env.ts +20 -2
  95. package/src/context.ts +62 -17
  96. package/src/index.ts +1 -0
  97. package/src/lexicon/lexicons.ts +44 -3
  98. package/src/lexicon/types/app/bsky/actor/defs.ts +2 -0
  99. package/src/lexicon/types/app/bsky/actor/profile.ts +1 -0
  100. package/src/lexicon/types/app/bsky/feed/defs.ts +38 -2
  101. package/src/lexicon/types/app/bsky/feed/getAuthorFeed.ts +1 -0
  102. package/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts +2 -0
  103. package/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +2 -0
  104. package/src/mailer/index.ts +1 -1
  105. package/src/mailer/templates/confirm-email.hbs +106 -336
  106. package/src/mailer/templates/delete-account.hbs +110 -346
  107. package/src/mailer/templates/plc-operation.hbs +107 -338
  108. package/src/mailer/templates/reset-password.d.ts +1 -1
  109. package/src/mailer/templates/reset-password.hbs +108 -344
  110. package/src/mailer/templates/update-email.hbs +107 -337
  111. package/src/pipethrough.ts +489 -233
  112. package/src/read-after-write/util.ts +58 -32
  113. package/tests/account-deletion.test.ts +1 -1
  114. package/tests/account.test.ts +2 -2
  115. package/tests/email-confirmation.test.ts +2 -2
  116. package/tests/plc-operations.test.ts +1 -1
  117. package/tests/proxied/proxy-catchall.test.ts +255 -0
  118. package/tests/proxied/proxy-header.test.ts +31 -1
@@ -1,261 +1,552 @@
1
1
  import express from 'express'
2
- import * as ui8 from 'uint8arrays'
3
- import net from 'node:net'
4
- import stream from 'node:stream'
5
- import webStream from 'node:stream/web'
6
- import { LexValue, jsonToLex, stringifyLex } from '@atproto/lexicon'
2
+ import { IncomingHttpHeaders, ServerResponse } from 'node:http'
3
+ import { PassThrough, Readable } from 'node:stream'
4
+ import { Dispatcher } from 'undici'
5
+
6
+ import {
7
+ decodeStream,
8
+ getServiceEndpoint,
9
+ omit,
10
+ streamToNodeBuffer,
11
+ } from '@atproto/common'
12
+ import { ResponseType, XRPCError as XRPCClientError } from '@atproto/xrpc'
7
13
  import {
8
14
  CatchallHandler,
9
- HandlerPipeThrough,
15
+ HandlerPipeThroughBuffer,
16
+ HandlerPipeThroughStream,
17
+ InternalServerError,
10
18
  InvalidRequestError,
11
19
  parseReqNsid,
20
+ XRPCError as XRPCServerError,
12
21
  } from '@atproto/xrpc-server'
13
- import { ResponseType, XRPCError } from '@atproto/xrpc'
14
- import { getServiceEndpoint, noUndefinedVals } from '@atproto/common'
15
- import { ids, lexicons } from './lexicon/lexicons'
16
- import { httpLogger } from './logger'
22
+
17
23
  import AppContext from './context'
24
+ import { ids } from './lexicon/lexicons'
25
+ import { httpLogger } from './logger'
18
26
 
19
27
  export const proxyHandler = (ctx: AppContext): CatchallHandler => {
20
28
  const accessStandard = ctx.authVerifier.accessStandard()
21
29
  return async (req, res, next) => {
30
+ // /!\ Hot path
31
+
22
32
  try {
23
- const { url, aud, nsid } = await formatUrlAndAud(ctx, req)
24
- const auth = await accessStandard({ req, res })
25
33
  if (
26
- PROTECTED_METHODS.has(nsid) ||
27
- (!auth.credentials.isPrivileged && PRIVILEGED_METHODS.has(nsid))
34
+ req.method !== 'GET' &&
35
+ req.method !== 'HEAD' &&
36
+ req.method !== 'POST'
28
37
  ) {
38
+ throw new XRPCServerError(
39
+ ResponseType.InvalidRequest,
40
+ 'XRPC requests only supports GET and POST',
41
+ )
42
+ }
43
+
44
+ const body = req.method === 'POST' ? req : undefined
45
+ if (body != null && !body.readable) {
46
+ // Body was already consumed by a previous middleware
47
+ throw new InternalServerError('Request body is not readable')
48
+ }
49
+
50
+ const lxm = parseReqNsid(req)
51
+ if (PROTECTED_METHODS.has(lxm)) {
29
52
  throw new InvalidRequestError('Bad token method', 'InvalidToken')
30
53
  }
31
- const headers = await formatHeaders(ctx, req, {
32
- aud,
33
- lxm: nsid,
34
- requester: auth.credentials.did,
54
+
55
+ const auth = await accessStandard({ req, res })
56
+ if (!auth.credentials.isPrivileged && PRIVILEGED_METHODS.has(lxm)) {
57
+ throw new InvalidRequestError('Bad token method', 'InvalidToken')
58
+ }
59
+
60
+ const { url: origin, did: aud } = await parseProxyInfo(ctx, req, lxm)
61
+
62
+ const headers: IncomingHttpHeaders = {
63
+ 'accept-encoding': req.headers['accept-encoding'],
64
+ 'accept-language': req.headers['accept-language'],
65
+ 'atproto-accept-labelers': req.headers['atproto-accept-labelers'],
66
+ 'x-bsky-topics': req.headers['x-bsky-topics'],
67
+
68
+ 'content-type': body && req.headers['content-type'],
69
+ 'content-encoding': body && req.headers['content-encoding'],
70
+ 'content-length': body && req.headers['content-length'],
71
+
72
+ authorization: auth.credentials.did
73
+ ? `Bearer ${await ctx.serviceAuthJwt(auth.credentials.did, aud, lxm)}`
74
+ : undefined,
75
+ }
76
+
77
+ const dispatchOptions: Dispatcher.RequestOptions = {
78
+ origin,
79
+ method: req.method,
80
+ path: req.originalUrl,
81
+ body,
82
+ headers,
83
+ }
84
+
85
+ await pipethroughStream(ctx, dispatchOptions, (upstream) => {
86
+ res.status(upstream.statusCode)
87
+
88
+ for (const [name, val] of responseHeaders(upstream.headers)) {
89
+ res.setHeader(name, val)
90
+ }
91
+
92
+ // Note that we should not need to manually handle errors here (e.g. by
93
+ // destroying the response), as the http server will handle them for us.
94
+ res.on('error', logResponseError)
95
+
96
+ // Tell undici to write the upstream response directly to the response
97
+ return res
35
98
  })
36
- const body: webStream.ReadableStream<Uint8Array> =
37
- stream.Readable.toWeb(req)
38
- const reqInit = formatReqInit(req, headers, body)
39
- const proxyRes = await makeRequest(url, reqInit)
40
- await pipeProxyRes(proxyRes, res)
41
99
  } catch (err) {
42
- return next(err)
100
+ next(err)
43
101
  }
44
- return next()
45
102
  }
46
103
  }
47
104
 
48
- export const pipethrough = async (
49
- ctx: AppContext,
50
- req: express.Request,
51
- requester: string | null,
52
- override: {
53
- aud?: string
54
- lxm?: string
55
- } = {},
56
- ): Promise<HandlerPipeThrough> => {
57
- const { url, aud, nsid } = await formatUrlAndAud(ctx, req, override.aud)
58
- const lxm = override.lxm ?? nsid
59
- const headers = await formatHeaders(ctx, req, { aud, lxm, requester })
60
- const reqInit = formatReqInit(req, headers)
61
- const res = await makeRequest(url, reqInit)
62
- return parseProxyRes(res)
105
+ export type PipethroughOptions = {
106
+ /**
107
+ * Specify the issuer (requester) for service auth. If not provided, no
108
+ * authorization headers will be added to the request.
109
+ */
110
+ iss?: string
111
+
112
+ /**
113
+ * Override the audience for service auth. If not provided, the audience will
114
+ * be determined based on the proxy service.
115
+ */
116
+ aud?: string
117
+
118
+ /**
119
+ * Override the lexicon method for service auth. If not provided, the lexicon
120
+ * method will be determined based on the request path.
121
+ */
122
+ lxm?: string
63
123
  }
64
124
 
65
- export const pipethroughProcedure = async (
125
+ // List of content encodings that are supported by the PDS. Because proxying
126
+ // occurs between data centers, where connectivity is supposedly stable & good,
127
+ // and because payloads are small, we prefer encoding that are fast (gzip,
128
+ // deflate, identity) over heavier encodings (Brotli). Upstream servers should
129
+ // be configured to prefer any encoding over identity in case of big,
130
+ // uncompressed payloads.
131
+ const SUPPORTED_ENCODINGS = [
132
+ ['gzip', { q: '1.0' }],
133
+ ['deflate', { q: '0.9' }],
134
+ ['identity', { q: '0.3' }],
135
+ ['br', { q: '0.1' }],
136
+ ] as const satisfies Accept[]
137
+
138
+ export async function pipethrough(
66
139
  ctx: AppContext,
67
140
  req: express.Request,
68
- requester: string | null,
69
- body?: LexValue,
70
- ): Promise<HandlerPipeThrough> => {
71
- const { url, aud, nsid: lxm } = await formatUrlAndAud(ctx, req)
72
- const headers = await formatHeaders(ctx, req, { aud, lxm, requester })
73
- const encodedBody = body
74
- ? new TextEncoder().encode(stringifyLex(body))
75
- : undefined
76
- const reqInit = formatReqInit(req, headers, encodedBody)
77
- const res = await makeRequest(url, reqInit)
78
- return parseProxyRes(res)
79
- }
141
+ options?: PipethroughOptions,
142
+ ): Promise<{
143
+ stream: Readable
144
+ headers: Record<string, string>
145
+ encoding: string
146
+ }> {
147
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
148
+ // pipethrough() is used from within xrpcServer handlers, which means that
149
+ // the request body either has been parsed or is a readable stream that has
150
+ // been piped for decoding & size limiting. Because of this, forwarding the
151
+ // request body requires re-encoding it. Since we currently do not use
152
+ // pipethrough() with procedures, proxying of request body is not
153
+ // implemented.
154
+ throw new InternalServerError(
155
+ `Proxying of ${req.method} requests is not supported`,
156
+ )
157
+ }
80
158
 
81
- // Request setup/formatting
82
- // -------------------
159
+ const lxm = parseReqNsid(req)
83
160
 
84
- const REQ_HEADERS_TO_FORWARD = [
85
- 'accept-language',
86
- 'content-type',
87
- 'atproto-accept-labelers',
88
- 'x-bsky-topics',
89
- ]
161
+ const { url: origin, did: aud } = await parseProxyInfo(ctx, req, lxm)
90
162
 
91
- export const formatUrlAndAud = async (
92
- ctx: AppContext,
93
- req: express.Request,
94
- audOverride?: string,
95
- ): Promise<{ url: URL; aud: string; nsid: string }> => {
96
- const proxyTo = await parseProxyHeader(ctx, req)
97
- const nsid = parseReqNsid(req)
98
- const defaultProxy = defaultService(ctx, nsid)
99
- const serviceUrl = proxyTo?.serviceUrl ?? defaultProxy?.url
100
- const aud = audOverride ?? proxyTo?.did ?? defaultProxy?.did
101
- if (!serviceUrl || !aud) {
102
- throw new InvalidRequestError(`No service configured for ${req.path}`)
163
+ // Because we sometimes need to interpret the response (e.g. during
164
+ // read-after-write, through asPipeThroughBuffer()), we need to ask the
165
+ // upstream server for an encoding that both the requester and the PDS can
166
+ // understand.
167
+ const acceptEncoding = negotiateAccept(
168
+ req.headers['accept-encoding'],
169
+ SUPPORTED_ENCODINGS,
170
+ )
171
+
172
+ const headers: IncomingHttpHeaders = {
173
+ 'accept-language': req.headers['accept-language'],
174
+ 'atproto-accept-labelers': req.headers['atproto-accept-labelers'],
175
+ 'x-bsky-topics': req.headers['x-bsky-topics'],
176
+
177
+ 'accept-encoding': `${formatAccepted(acceptEncoding)}, *;q=0`, // Reject anything else (q=0)
178
+
179
+ authorization: options?.iss
180
+ ? `Bearer ${await ctx.serviceAuthJwt(options.iss, options.aud ?? aud, options.lxm ?? lxm)}`
181
+ : undefined,
103
182
  }
104
- const url = new URL(req.originalUrl, serviceUrl)
105
- if (!ctx.cfg.service.devMode && !isSafeUrl(url)) {
106
- throw new InvalidRequestError(`Invalid service url: ${url.toString()}`)
183
+
184
+ const dispatchOptions: Dispatcher.RequestOptions = {
185
+ origin,
186
+ method: req.method,
187
+ path: req.originalUrl,
188
+ headers,
189
+
190
+ // Use a high water mark to buffer more data while performing async
191
+ // operations before this stream is consumed. This is especially useful
192
+ // while processing read-after-write operations.
193
+ highWaterMark: 2 * 65536, // twice the default (64KiB)
107
194
  }
108
- return { url, aud, nsid }
195
+
196
+ const upstream = await pipethroughRequest(ctx, dispatchOptions)
197
+
198
+ return {
199
+ stream: upstream.body,
200
+ headers: Object.fromEntries(responseHeaders(upstream.headers)),
201
+ encoding:
202
+ safeString(upstream.headers['content-type']) ?? 'application/json',
203
+ } satisfies HandlerPipeThroughStream
109
204
  }
110
205
 
111
- export const formatHeaders = async (
206
+ // Request setup/formatting
207
+ // -------------------
208
+
209
+ async function parseProxyInfo(
112
210
  ctx: AppContext,
113
211
  req: express.Request,
114
- opts: {
115
- aud: string
116
- lxm: string
117
- requester: string | null
118
- },
119
- ): Promise<{ authorization?: string }> => {
120
- const { aud, lxm, requester } = opts
121
- const headers = requester
122
- ? (await ctx.serviceAuthHeaders(requester, aud, lxm)).headers
123
- : {}
124
- // forward select headers to upstream services
125
- for (const header of REQ_HEADERS_TO_FORWARD) {
126
- const val = req.headers[header]
127
- if (val) {
128
- headers[header] = val
129
- }
130
- }
131
- return headers
132
- }
212
+ lxm: string,
213
+ ): Promise<{ url: string; did: string }> {
214
+ // /!\ Hot path
133
215
 
134
- const formatReqInit = (
135
- req: express.Request,
136
- headers: Record<string, string>,
137
- body?: Uint8Array | webStream.ReadableStream<Uint8Array>,
138
- ): RequestInit => {
139
- if (req.method === 'GET') {
140
- return {
141
- method: 'get',
142
- headers,
143
- }
144
- } else if (req.method === 'HEAD') {
145
- return {
146
- method: 'head',
147
- headers,
148
- }
149
- } else if (req.method === 'POST') {
150
- return {
151
- method: 'post',
152
- headers,
153
- body,
154
- duplex: 'half',
155
- } as RequestInit
156
- } else {
157
- throw new InvalidRequestError('Method not found')
158
- }
216
+ const proxyToHeader = req.header('atproto-proxy')
217
+ if (proxyToHeader) return parseProxyHeader(ctx, proxyToHeader)
218
+
219
+ const defaultProxy = defaultService(ctx, lxm)
220
+ if (defaultProxy) return defaultProxy
221
+
222
+ throw new InvalidRequestError(`No service configured for ${lxm}`)
159
223
  }
160
224
 
161
225
  export const parseProxyHeader = async (
162
- ctx: AppContext,
163
- req: express.Request,
164
- ): Promise<{ did: string; serviceUrl: string } | undefined> => {
165
- const proxyTo = req.header('atproto-proxy')
166
- if (!proxyTo) return
167
- const [did, serviceId] = proxyTo.split('#')
168
- if (!serviceId) {
169
- throw new InvalidRequestError('no service id specified')
226
+ // Using subset of AppContext for testing purposes
227
+ ctx: Pick<AppContext, 'idResolver'>,
228
+ proxyTo: string,
229
+ ): Promise<{ did: string; url: string }> => {
230
+ // /!\ Hot path
231
+
232
+ const hashIndex = proxyTo.indexOf('#')
233
+
234
+ if (hashIndex === 0) {
235
+ throw new InvalidRequestError('no did specified in proxy header')
236
+ }
237
+
238
+ if (hashIndex === -1 || hashIndex === proxyTo.length - 1) {
239
+ throw new InvalidRequestError('no service id specified in proxy header')
170
240
  }
241
+
242
+ // More than one hash
243
+ if (proxyTo.indexOf('#', hashIndex + 1) !== -1) {
244
+ throw new InvalidRequestError('invalid proxy header format')
245
+ }
246
+
247
+ // Basic validation
248
+ if (proxyTo.includes(' ')) {
249
+ throw new InvalidRequestError('proxy header cannot contain spaces')
250
+ }
251
+
252
+ const did = proxyTo.slice(0, hashIndex)
171
253
  const didDoc = await ctx.idResolver.did.resolve(did)
172
254
  if (!didDoc) {
173
255
  throw new InvalidRequestError('could not resolve proxy did')
174
256
  }
175
- const serviceUrl = getServiceEndpoint(didDoc, { id: `#${serviceId}` })
176
- if (!serviceUrl) {
257
+
258
+ const serviceId = proxyTo.slice(hashIndex)
259
+ const url = getServiceEndpoint(didDoc, { id: serviceId })
260
+ if (!url) {
177
261
  throw new InvalidRequestError('could not resolve proxy did service url')
178
262
  }
179
- return { did, serviceUrl }
263
+
264
+ return { did, url }
265
+ }
266
+
267
+ /**
268
+ * Utility function that wraps the undici stream() function and handles request
269
+ * and response errors by wrapping them in XRPCError instances. This function is
270
+ * more efficient than "pipethroughRequest" when a writable stream to pipe the
271
+ * upstream response to is available.
272
+ */
273
+ async function pipethroughStream(
274
+ ctx: AppContext,
275
+ dispatchOptions: Dispatcher.RequestOptions,
276
+ successStreamFactory: Dispatcher.StreamFactory,
277
+ ): Promise<void> {
278
+ return new Promise<void>((resolve, reject) => {
279
+ void ctx.proxyAgent
280
+ .stream(dispatchOptions, (upstream) => {
281
+ if (upstream.statusCode >= 400) {
282
+ const passThrough = new PassThrough()
283
+
284
+ void tryParsingError(upstream.headers, passThrough).then((parsed) => {
285
+ const xrpcError = new XRPCClientError(
286
+ upstream.statusCode === 500
287
+ ? ResponseType.UpstreamFailure
288
+ : upstream.statusCode,
289
+ parsed.error,
290
+ parsed.message,
291
+ Object.fromEntries(responseHeaders(upstream.headers, false)),
292
+ { cause: dispatchOptions },
293
+ )
294
+
295
+ reject(xrpcError)
296
+ }, reject)
297
+
298
+ return passThrough
299
+ }
300
+
301
+ const writable = successStreamFactory(upstream)
302
+
303
+ // As soon as the control was passed to the writable stream (i.e. by
304
+ // returning the writable hereafter), pipethroughStream() is considered
305
+ // to have succeeded. Any error occurring while writing upstream data to
306
+ // the writable stream should be handled through the stream's error
307
+ // state (i.e. successStreamFactory() must ensure that error events on
308
+ // the returned writable will be handled).
309
+ resolve()
310
+
311
+ return writable
312
+ })
313
+ // The following catch block will be triggered with either network errors
314
+ // or writable stream errors. In the latter case, the promise will already
315
+ // be resolved, and reject()ing it there after will have no effect. Those
316
+ // error would still be logged by the successStreamFactory() function.
317
+ .catch(handleUpstreamRequestError)
318
+ .catch(reject)
319
+ })
320
+ }
321
+
322
+ /**
323
+ * Utility function that wraps the undici request() function and handles request
324
+ * and response errors by wrapping them in XRPCError instances.
325
+ */
326
+ async function pipethroughRequest(
327
+ ctx: AppContext,
328
+ dispatchOptions: Dispatcher.RequestOptions,
329
+ ) {
330
+ // HandlerPipeThroughStream requires a readable stream to be returned, so we
331
+ // use the (less efficient) request() function instead.
332
+
333
+ const upstream = await ctx.proxyAgent
334
+ .request(dispatchOptions)
335
+ .catch(handleUpstreamRequestError)
336
+
337
+ if (upstream.statusCode >= 400) {
338
+ const parsed = await tryParsingError(upstream.headers, upstream.body)
339
+
340
+ // Note "XRPCClientError" is used instead of "XRPCServerError" in order to
341
+ // allow users of this function to capture & handle these errors (namely in
342
+ // "app.bsky.feed.getPostThread").
343
+ throw new XRPCClientError(
344
+ upstream.statusCode === 500
345
+ ? ResponseType.UpstreamFailure
346
+ : upstream.statusCode,
347
+ parsed.error,
348
+ parsed.message,
349
+ Object.fromEntries(responseHeaders(upstream.headers, false)),
350
+ { cause: dispatchOptions },
351
+ )
352
+ }
353
+
354
+ return upstream
180
355
  }
181
356
 
182
- // Sending request
357
+ function handleUpstreamRequestError(
358
+ err: unknown,
359
+ message = 'pipethrough network error',
360
+ ): never {
361
+ httpLogger.warn({ err }, message)
362
+ throw new XRPCServerError(ResponseType.UpstreamFailure, message, undefined, {
363
+ cause: err,
364
+ })
365
+ }
366
+
367
+ // Request parsing/forwarding
183
368
  // -------------------
184
369
 
185
- export const makeRequest = async (
186
- url: URL,
187
- reqInit: RequestInit,
188
- ): Promise<Response> => {
189
- let res: Response
190
- try {
191
- res = await fetch(url, reqInit)
192
- } catch (err) {
193
- httpLogger.warn({ err }, 'pipethrough network error')
194
- throw new XRPCError(ResponseType.UpstreamFailure)
370
+ type Accept = [name: string, flags: Record<string, string>]
371
+
372
+ function negotiateAccept(
373
+ acceptHeader: undefined | string | string[],
374
+ supported: readonly Accept[],
375
+ ): readonly Accept[] {
376
+ // Optimization: if no accept-encoding header is present, skip negotiation
377
+ if (!acceptHeader?.length) {
378
+ return supported
195
379
  }
196
- if (res.status !== ResponseType.Success) {
197
- const arrBuffer = await readArrayBufferRes(res)
198
- const ui8Buffer = new Uint8Array(arrBuffer)
199
- const errInfo = safeParseJson(ui8.toString(ui8Buffer, 'utf8'))
200
- throw new XRPCError(
201
- res.status,
202
- safeString(errInfo?.['error']),
203
- safeString(errInfo?.['message']),
204
- simpleHeaders(res.headers),
380
+
381
+ const acceptNames = extractAcceptedNames(acceptHeader)
382
+ const common = acceptNames.includes('*')
383
+ ? supported
384
+ : supported.filter(nameIncludedIn, acceptNames)
385
+
386
+ // There must be at least one common encoding with a non-zero q value
387
+ if (!common.some(isNotRejected)) {
388
+ throw new XRPCServerError(
389
+ ResponseType.NotAcceptable,
390
+ 'this service does not support any of the requested encodings',
205
391
  )
206
392
  }
207
- return res
393
+
394
+ return common
208
395
  }
209
396
 
210
- // Response parsing/forwarding
211
- // -------------------
397
+ function formatAccepted(accept: readonly Accept[]): string {
398
+ return accept.map(formatEncodingDev).join(', ')
399
+ }
400
+
401
+ function formatEncodingDev([enc, flags]: Accept): string {
402
+ let ret = enc
403
+ for (const name in flags) ret += `;${name}=${flags[name]}`
404
+ return ret
405
+ }
406
+
407
+ function nameIncludedIn(this: readonly string[], accept: Accept): boolean {
408
+ return this.includes(accept[0])
409
+ }
410
+
411
+ function isNotRejected(accept: Accept): boolean {
412
+ return accept[1]['q'] !== '0'
413
+ }
414
+
415
+ function extractAcceptedNames(
416
+ acceptHeader: undefined | string | string[],
417
+ ): string[] {
418
+ if (!acceptHeader?.length) {
419
+ return ['*']
420
+ }
421
+
422
+ return Array.isArray(acceptHeader)
423
+ ? acceptHeader.flatMap(extractAcceptedNames)
424
+ : acceptHeader.split(',').map(extractAcceptedName).filter(isNonNullable)
425
+ }
426
+
427
+ function extractAcceptedName(def: string): string | undefined {
428
+ // No need to fully parse since we only care about allowed values
429
+ const parts = def.split(';')
430
+ if (parts.some(isQzero)) return undefined
431
+ return parts[0].trim()
432
+ }
433
+
434
+ function isQzero(def: string): boolean {
435
+ return def.trim() === 'q=0'
436
+ }
437
+
438
+ function isNonNullable<T>(val: T): val is NonNullable<T> {
439
+ return val != null
440
+ }
441
+
442
+ export function isJsonContentType(contentType?: string): boolean | undefined {
443
+ if (contentType == null) return undefined
444
+ return /application\/(?:\w+\+)?json/i.test(contentType)
445
+ }
446
+
447
+ async function tryParsingError(
448
+ headers: IncomingHttpHeaders,
449
+ readable: Readable,
450
+ ): Promise<{ error?: string; message?: string }> {
451
+ if (isJsonContentType(headers['content-type']) === false) {
452
+ // We don't known how to parse non JSON content types so we can discard the
453
+ // whole response.
454
+ //
455
+ // @NOTE we could also simply "drain" the stream here. This would prevent
456
+ // the upstream HTTP/1.1 connection from getting destroyed (closed). This
457
+ // would however imply to read the whole upstream response, which would be
458
+ // costly in terms of bandwidth and I/O processing. It is recommended to use
459
+ // HTTP/2 to avoid this issue (be able to destroy a single response stream
460
+ // without resetting the whole connection). This is not expected to happen
461
+ // too much as 4xx and 5xx responses are expected to be JSON.
462
+ readable.destroy()
212
463
 
213
- const RES_HEADERS_TO_FORWARD = [
214
- 'content-type',
215
- 'content-language',
216
- 'atproto-repo-rev',
217
- 'atproto-content-labelers',
218
- ]
219
-
220
- export const pipeProxyRes = async (
221
- upstreamRes: Response,
222
- ownRes: express.Response,
223
- ) => {
224
- for (const headerName of RES_HEADERS_TO_FORWARD) {
225
- const headerVal = upstreamRes.headers.get(headerName)
226
- if (headerVal) {
227
- ownRes.setHeader(headerName, headerVal)
464
+ return {}
465
+ }
466
+
467
+ try {
468
+ const buffer = await bufferUpstreamResponse(
469
+ readable,
470
+ headers['content-encoding'],
471
+ )
472
+
473
+ const errInfo: unknown = JSON.parse(buffer.toString('utf8'))
474
+ return {
475
+ error: safeString(errInfo?.['error']),
476
+ message: safeString(errInfo?.['message']),
228
477
  }
478
+ } catch (err) {
479
+ // Failed to read, decode, buffer or parse. No big deal.
480
+ return {}
229
481
  }
230
- if (upstreamRes.body) {
231
- const contentLength = upstreamRes.headers.get('content-length')
232
- const contentEncoding = upstreamRes.headers.get('content-encoding')
233
- if (contentLength && (!contentEncoding || contentEncoding === 'identity')) {
234
- ownRes.setHeader('content-length', contentLength)
235
- } else {
236
- ownRes.setHeader('transfer-encoding', 'chunked')
482
+ }
483
+
484
+ export async function bufferUpstreamResponse(
485
+ readable: Readable,
486
+ contentEncoding?: string | string[],
487
+ ): Promise<Buffer> {
488
+ try {
489
+ // Needed for type-safety (should never happen irl)
490
+ if (Array.isArray(contentEncoding)) {
491
+ throw new TypeError(
492
+ 'upstream service returned multiple content-encoding headers',
493
+ )
237
494
  }
238
- ownRes.status(200)
239
- const resStream = stream.Readable.fromWeb(
240
- upstreamRes.body as webStream.ReadableStream<Uint8Array>,
495
+
496
+ return await streamToNodeBuffer(decodeStream(readable, contentEncoding))
497
+ } catch (err) {
498
+ if (!readable.destroyed) readable.destroy()
499
+
500
+ throw new XRPCServerError(
501
+ ResponseType.UpstreamFailure,
502
+ err instanceof TypeError ? err.message : 'unable to decode request body',
503
+ undefined,
504
+ { cause: err },
241
505
  )
242
- await stream.promises.pipeline(resStream, ownRes)
243
- } else {
244
- ownRes.status(200).end()
245
506
  }
246
507
  }
247
508
 
248
- export const parseProxyRes = async (res: Response) => {
249
- const buffer = await readArrayBufferRes(res)
250
- const encoding = res.headers.get('content-type') ?? 'application/json'
251
- const resHeaders = RES_HEADERS_TO_FORWARD.reduce(
252
- (acc, cur) => {
253
- acc[cur] = res.headers.get(cur) ?? undefined
254
- return acc
255
- },
256
- {} as Record<string, string | undefined>,
257
- )
258
- return { encoding, buffer, headers: noUndefinedVals(resHeaders) }
509
+ export async function asPipeThroughBuffer(
510
+ input: HandlerPipeThroughStream,
511
+ ): Promise<HandlerPipeThroughBuffer> {
512
+ return {
513
+ buffer: await bufferUpstreamResponse(
514
+ input.stream,
515
+ input.headers?.['content-encoding'],
516
+ ),
517
+ headers: omit(input.headers, ['content-encoding', 'content-length']),
518
+ encoding: input.encoding,
519
+ }
520
+ }
521
+
522
+ // Response parsing/forwarding
523
+ // -------------------
524
+
525
+ const RES_HEADERS_TO_FORWARD = ['atproto-repo-rev', 'atproto-content-labelers']
526
+
527
+ function* responseHeaders(
528
+ headers: IncomingHttpHeaders,
529
+ includeContentHeaders = true,
530
+ ): Generator<[string, string]> {
531
+ if (includeContentHeaders) {
532
+ const length = headers['content-length']
533
+ if (length) yield ['content-length', length]
534
+
535
+ const encoding = headers['content-encoding']
536
+ if (encoding) yield ['content-encoding', encoding]
537
+
538
+ const type = headers['content-type']
539
+ if (type) yield ['content-type', type]
540
+
541
+ const language = headers['content-language']
542
+ if (language) yield ['content-language', language]
543
+ }
544
+
545
+ for (let i = 0; i < RES_HEADERS_TO_FORWARD.length; i++) {
546
+ const name = RES_HEADERS_TO_FORWARD[i]
547
+ const val = headers[name]
548
+ if (typeof val === 'string') yield [name, val]
549
+ }
259
550
  }
260
551
 
261
552
  // Utils
@@ -328,45 +619,10 @@ const defaultService = (
328
619
  }
329
620
  }
330
621
 
331
- export const parseRes = <T>(nsid: string, res: HandlerPipeThrough): T => {
332
- const buffer = new Uint8Array(res.buffer)
333
- const json = safeParseJson(ui8.toString(buffer, 'utf8'))
334
- const lex = json && jsonToLex(json)
335
- return lexicons.assertValidXrpcOutput(nsid, lex) as T
336
- }
337
-
338
- const readArrayBufferRes = async (res: Response): Promise<ArrayBuffer> => {
339
- try {
340
- return await res.arrayBuffer()
341
- } catch (err) {
342
- httpLogger.warn({ err }, 'pipethrough network error')
343
- throw new XRPCError(ResponseType.UpstreamFailure)
344
- }
345
- }
346
-
347
- const isSafeUrl = (url: URL) => {
348
- if (url.protocol !== 'https:') return false
349
- if (!url.hostname || url.hostname === 'localhost') return false
350
- if (net.isIP(url.hostname) !== 0) return false
351
- return true
352
- }
353
-
354
- const safeString = (str: string): string | undefined => {
622
+ const safeString = (str: unknown): string | undefined => {
355
623
  return typeof str === 'string' ? str : undefined
356
624
  }
357
625
 
358
- const safeParseJson = (json: string): unknown => {
359
- try {
360
- return JSON.parse(json)
361
- } catch {
362
- return null
363
- }
364
- }
365
-
366
- const simpleHeaders = (headers: Headers): Record<string, string> => {
367
- const result = {}
368
- for (const [key, val] of headers) {
369
- result[key] = val
370
- }
371
- return result
626
+ function logResponseError(this: ServerResponse, err: unknown): void {
627
+ httpLogger.warn({ err }, 'error forwarding upstream response')
372
628
  }