@atproto/bsky 0.0.103 → 0.0.105

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 (65) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/api/blob-dispatcher.d.ts +4 -0
  3. package/dist/api/blob-dispatcher.d.ts.map +1 -0
  4. package/dist/api/blob-dispatcher.js +37 -0
  5. package/dist/api/blob-dispatcher.js.map +1 -0
  6. package/dist/api/blob-resolver.d.ts +17 -8
  7. package/dist/api/blob-resolver.d.ts.map +1 -1
  8. package/dist/api/blob-resolver.js +246 -99
  9. package/dist/api/blob-resolver.js.map +1 -1
  10. package/dist/api/well-known.d.ts.map +1 -1
  11. package/dist/api/well-known.js +30 -24
  12. package/dist/api/well-known.js.map +1 -1
  13. package/dist/config.d.ts +14 -1
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +40 -5
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts +3 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +3 -0
  20. package/dist/context.js.map +1 -1
  21. package/dist/data-plane/server/indexing/index.js +2 -2
  22. package/dist/image/server.d.ts +7 -13
  23. package/dist/image/server.d.ts.map +1 -1
  24. package/dist/image/server.js +119 -115
  25. package/dist/image/server.js.map +1 -1
  26. package/dist/image/sharp.d.ts +11 -2
  27. package/dist/image/sharp.d.ts.map +1 -1
  28. package/dist/image/sharp.js +35 -38
  29. package/dist/image/sharp.js.map +1 -1
  30. package/dist/image/util.d.ts +6 -4
  31. package/dist/image/util.d.ts.map +1 -1
  32. package/dist/image/util.js +14 -10
  33. package/dist/image/util.js.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -10
  37. package/dist/index.js.map +1 -1
  38. package/dist/util/http.d.ts +12 -0
  39. package/dist/util/http.d.ts.map +1 -0
  40. package/dist/util/http.js +36 -0
  41. package/dist/util/http.js.map +1 -0
  42. package/dist/util/retry.d.ts +2 -5
  43. package/dist/util/retry.d.ts.map +1 -1
  44. package/dist/util/retry.js +8 -27
  45. package/dist/util/retry.js.map +1 -1
  46. package/package.json +18 -14
  47. package/src/api/blob-dispatcher.ts +38 -0
  48. package/src/api/blob-resolver.ts +341 -106
  49. package/src/api/well-known.ts +31 -24
  50. package/src/config.ts +63 -6
  51. package/src/context.ts +6 -0
  52. package/src/data-plane/server/indexing/index.ts +3 -3
  53. package/src/image/server.ts +131 -107
  54. package/src/image/sharp.ts +48 -52
  55. package/src/image/util.ts +20 -12
  56. package/src/index.ts +8 -15
  57. package/src/util/http.ts +41 -0
  58. package/src/util/retry.ts +8 -32
  59. package/tests/_util.ts +50 -3
  60. package/tests/blob-resolver.test.ts +62 -36
  61. package/tests/image/server.test.ts +40 -32
  62. package/tests/image/sharp.test.ts +17 -4
  63. package/tests/label-hydration.test.ts +6 -6
  64. package/tests/server.test.ts +41 -56
  65. package/tsconfig.build.tsbuildinfo +1 -1
package/src/context.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  parseLabelerHeader,
18
18
  } from './util'
19
19
  import { httpLogger as log } from './logger'
20
+ import { Dispatcher } from 'undici'
20
21
 
21
22
  export class AppContext {
22
23
  constructor(
@@ -34,6 +35,7 @@ export class AppContext {
34
35
  courierClient: CourierClient | undefined
35
36
  authVerifier: AuthVerifier
36
37
  featureGates: FeatureGates
38
+ blobDispatcher: Dispatcher
37
39
  },
38
40
  ) {}
39
41
 
@@ -93,6 +95,10 @@ export class AppContext {
93
95
  return this.opts.featureGates
94
96
  }
95
97
 
98
+ get blobDispatcher(): Dispatcher {
99
+ return this.opts.blobDispatcher
100
+ }
101
+
96
102
  reqLabelers(req: express.Request): ParsedLabelers {
97
103
  const val = req.header('atproto-accept-labelers')
98
104
  let parsed: ParsedLabelers | null
@@ -31,7 +31,7 @@ import * as Labeler from './plugins/labeler'
31
31
  import * as ChatDeclaration from './plugins/chat-declaration'
32
32
  import RecordProcessor from './processor'
33
33
  import { subLogger } from '../../../logger'
34
- import { retryHttp } from '../../../util/retry'
34
+ import { retryXrpc } from '../../../util/retry'
35
35
  import { BackgroundQueue } from '../background'
36
36
 
37
37
  export class IndexingService {
@@ -165,7 +165,7 @@ export class IndexingService {
165
165
  )
166
166
  const { api } = new AtpAgent({ service: pds })
167
167
 
168
- const { data: car } = await retryHttp(() =>
168
+ const { data: car } = await retryXrpc(() =>
169
169
  api.com.atproto.sync.getRepo({ did }),
170
170
  )
171
171
  const { root, blocks } = await readCarWithRoot(car)
@@ -287,7 +287,7 @@ export class IndexingService {
287
287
  if (!pds) return false
288
288
  const { api } = new AtpAgent({ service: pds })
289
289
  try {
290
- await retryHttp(() => api.com.atproto.sync.getLatestCommit({ did }))
290
+ await retryXrpc(() => api.com.atproto.sync.getLatestCommit({ did }))
291
291
  return true
292
292
  } catch (err) {
293
293
  if (err instanceof ComAtprotoSyncGetLatestCommit.RepoNotFoundError) {
@@ -1,136 +1,170 @@
1
- import fs from 'fs/promises'
2
- import fsSync from 'fs'
3
- import os from 'os'
4
- import path from 'path'
5
- import { Readable } from 'stream'
6
- import axios, { AxiosError } from 'axios'
7
- import express, {
8
- Request,
9
- Response,
10
- Express,
11
- ErrorRequestHandler,
12
- NextFunction,
13
- } from 'express'
14
- import createError, { isHttpError } from 'http-errors'
15
- import { BlobNotFoundError } from '@atproto/repo'
16
1
  import {
17
2
  cloneStream,
18
- forwardStreamErrors,
3
+ createDecoders,
19
4
  isErrnoException,
5
+ VerifyCidError,
6
+ VerifyCidTransform,
20
7
  } from '@atproto/common'
21
- import { BadPathError, ImageUriBuilder } from './uri'
8
+ import { BlobNotFoundError } from '@atproto/repo'
9
+ import createError, { isHttpError } from 'http-errors'
10
+ import fsSync from 'node:fs'
11
+ import fs from 'node:fs/promises'
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+ import { Duplex, Readable } from 'node:stream'
15
+ import { pipeline } from 'node:stream/promises'
16
+
17
+ import { streamBlob, StreamBlobOptions } from '../api/blob-resolver'
18
+ import AppContext from '../context'
19
+ import { Middleware, responseSignal } from '../util/http'
22
20
  import log from './logger'
23
- import { resize } from './sharp'
24
- import { formatsToMimes, Options } from './util'
25
- import { retryHttp } from '../util/retry'
26
- import { ServerConfig } from '../config'
27
-
28
- export class ImageProcessingServer {
29
- app: Express = express()
30
- uriBuilder: ImageUriBuilder
31
-
32
- constructor(
33
- public cfg: ServerConfig,
34
- public cache: BlobCache,
35
- ) {
36
- this.uriBuilder = new ImageUriBuilder('')
37
- this.app.get('*', this.handler.bind(this))
38
- this.app.use(errorMiddleware)
21
+ import { createImageProcessor, createImageUpscaler } from './sharp'
22
+ import { BadPathError, ImageUriBuilder } from './uri'
23
+ import { formatsToMimes, Options, SharpInfo } from './util'
24
+
25
+ export function createMiddleware(
26
+ ctx: AppContext,
27
+ { prefix = '/' }: { prefix?: string } = {},
28
+ ): Middleware {
29
+ if (!prefix.startsWith('/') || !prefix.endsWith('/')) {
30
+ throw new TypeError('Prefix must start and end with a slash')
31
+ }
32
+
33
+ // If there is a CDN, we don't need to serve images
34
+ if (ctx.cfg.cdnUrl) {
35
+ return (req, res, next) => next()
39
36
  }
40
37
 
41
- async handler(req: Request, res: Response, next: NextFunction) {
38
+ const cache = new BlobDiskCache(ctx.cfg.blobCacheLocation)
39
+
40
+ return async (req, res, next) => {
41
+ if (res.destroyed) return
42
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next()
43
+ if (!req.url?.startsWith(prefix)) return next()
44
+ const { 0: path, 1: _search } = req.url.slice(prefix.length - 1).split('?')
45
+ if (!path.startsWith('/') || path === '/') return next()
46
+
42
47
  try {
43
- const path = req.path
44
48
  const options = ImageUriBuilder.getOptions(path)
45
- const cacheKey = [
46
- options.did,
47
- options.cid.toString(),
48
- options.preset,
49
- ].join('::')
49
+
50
+ const cacheKey = [options.did, options.cid, options.preset].join('::')
50
51
 
51
52
  // Cached flow
52
53
 
53
54
  try {
54
- const cachedImage = await this.cache.get(cacheKey)
55
+ const cachedImage = await cache.get(cacheKey)
55
56
  res.statusCode = 200
56
57
  res.setHeader('x-cache', 'hit')
57
58
  res.setHeader('content-type', getMime(options.format))
58
59
  res.setHeader('cache-control', `public, max-age=31536000`) // 1 year
59
60
  res.setHeader('content-length', cachedImage.size)
60
- forwardStreamErrors(cachedImage, res)
61
- return cachedImage.pipe(res)
61
+ await pipeline(cachedImage, res)
62
+ return
62
63
  } catch (err) {
63
- // Ignore BlobNotFoundError and move on to non-cached flow
64
- if (!(err instanceof BlobNotFoundError)) throw err
64
+ if (!(err instanceof BlobNotFoundError)) {
65
+ log.error({ cacheKey, err }, 'failed to serve cached image')
66
+ }
67
+
68
+ if (res.headersSent || res.destroyed) {
69
+ res.destroy()
70
+ return // nothing we can do...
71
+ } else {
72
+ // Ignore and move on to non-cached flow.
73
+ res.removeHeader('x-cache')
74
+ res.removeHeader('content-type')
75
+ res.removeHeader('cache-control')
76
+ res.removeHeader('content-length')
77
+ }
65
78
  }
66
79
 
67
80
  // Non-cached flow
68
81
 
69
- const { localUrl } = this.cfg
70
- const did = options.did
71
- const cidStr = options.cid.toString()
72
-
73
- const blobResult = await retryHttp(() =>
74
- getBlob({ baseUrl: localUrl, did, cid: cidStr }),
75
- )
76
-
77
- const imageStream: Readable = blobResult.data
78
- const processedImage = await resize(imageStream, options)
79
-
80
- // Cache in the background
81
- this.cache
82
- .put(cacheKey, cloneStream(processedImage))
83
- .catch((err) => log.error(err, 'failed to cache image'))
84
- // Respond
85
- res.statusCode = 200
86
- res.setHeader('x-cache', 'miss')
87
- res.setHeader('content-type', getMime(options.format))
88
- res.setHeader('cache-control', `public, max-age=31536000`) // 1 year
89
- forwardStreamErrors(processedImage, res)
90
- return (
91
- processedImage
92
- // @NOTE sharp does emit this in time to be set as a header
93
- .once('info', (info) => res.setHeader('content-length', info.size))
94
- .pipe(res)
95
- )
96
- } catch (err: unknown) {
97
- if (err instanceof BadPathError) {
98
- return next(createError(400, err))
82
+ const streamOptions: StreamBlobOptions = {
83
+ did: options.did,
84
+ cid: options.cid,
85
+ signal: responseSignal(res),
99
86
  }
100
- if (err instanceof AxiosError) {
101
- if (err.code === AxiosError.ETIMEDOUT) {
102
- return next(createError(504)) // Gateway timeout
103
- }
104
- if (!err.response || err.response.status >= 500) {
105
- return next(createError(502))
87
+
88
+ await streamBlob(ctx, streamOptions, (upstream, { did, cid, url }) => {
89
+ // Definitely not an image ? Let's fail right away.
90
+ if (isImageMime(upstream.headers['content-type']) === false) {
91
+ throw createError(400, 'Not an image')
106
92
  }
107
- if (err.response.status === 400) {
108
- return next(createError(400))
93
+
94
+ // Let's transform (decompress, verify CID, upscale), process and respond
95
+
96
+ const transforms: Duplex[] = [
97
+ ...createDecoders(upstream.headers['content-encoding']),
98
+ new VerifyCidTransform(cid),
99
+ createImageUpscaler(options),
100
+ ]
101
+ const processor = createImageProcessor(options)
102
+
103
+ // Cache in the background
104
+ cache
105
+ .put(cacheKey, cloneStream(processor))
106
+ .catch((err) => log.error(err, 'failed to cache image'))
107
+
108
+ res.statusCode = 200
109
+ res.setHeader('cache-control', `public, max-age=31536000`) // 1 year
110
+ res.setHeader('x-cache', 'miss')
111
+ processor.once('info', ({ size, format }: SharpInfo) => {
112
+ const type = formatsToMimes.get(format) || 'application/octet-stream'
113
+
114
+ // @NOTE sharp does emit this in time to be set as a header
115
+ res.setHeader('content-length', size)
116
+ res.setHeader('content-type', type)
117
+ })
118
+
119
+ const streams = [...transforms, processor, res]
120
+ void pipeline(streams).catch((err: unknown) => {
121
+ log.warn(
122
+ { err, did, cid: cid.toString(), pds: url.origin },
123
+ 'blob resolution failed during transmission',
124
+ )
125
+ })
126
+
127
+ return streams[0]!
128
+ })
129
+ } catch (err) {
130
+ if (res.headersSent || res.destroyed) {
131
+ res.destroy()
132
+ } else {
133
+ res.removeHeader('content-type')
134
+ res.removeHeader('content-length')
135
+ res.removeHeader('cache-control')
136
+ res.removeHeader('x-cache')
137
+
138
+ if (err instanceof BadPathError) {
139
+ next(createError(400, err))
140
+ } else if (err instanceof VerifyCidError) {
141
+ next(createError(404, 'Blob not found', err))
142
+ } else if (isHttpError(err)) {
143
+ next(err)
144
+ } else {
145
+ next(createError(502, 'Upstream Error', { cause: err }))
109
146
  }
110
- return next(createError(404, 'Image not found'))
111
147
  }
112
- return next(err)
113
148
  }
114
149
  }
115
150
  }
116
151
 
117
- const errorMiddleware: ErrorRequestHandler = function (err, _req, res, next) {
118
- if (isHttpError(err)) {
119
- log.error(err, `error: ${err.message}`)
120
- } else {
121
- log.error(err, 'unhandled exception')
152
+ function isImageMime(
153
+ contentType: string | string[] | undefined,
154
+ ): undefined | boolean {
155
+ if (contentType == null || contentType === 'application/octet-stream') {
156
+ return undefined // maybe
122
157
  }
123
- if (res.headersSent) {
124
- return next(err)
158
+ if (Array.isArray(contentType)) {
159
+ if (contentType.length === 0) return undefined // should never happen
160
+ if (contentType.length === 1) return isImageMime(contentType[0])
161
+ return contentType.every(isImageMime) // Should we throw a 502 here?
125
162
  }
126
- const httpError = createError(err)
127
- return res.status(httpError.status).json({
128
- message: httpError.expose ? httpError.message : 'Internal Server Error',
129
- })
163
+ return contentType.startsWith('image/')
130
164
  }
131
165
 
132
166
  function getMime(format: Options['format']) {
133
- const mime = formatsToMimes[format]
167
+ const mime = formatsToMimes.get(format)
134
168
  if (!mime) throw new Error('Unknown format')
135
169
  return mime
136
170
  }
@@ -193,13 +227,3 @@ export class BlobDiskCache implements BlobCache {
193
227
  await fs.rm(this.tempDir, { recursive: true, force: true })
194
228
  }
195
229
  }
196
-
197
- function getBlob(opts: { baseUrl: string; did: string; cid: string }) {
198
- const { baseUrl, did, cid } = opts
199
- const enc = encodeURIComponent
200
- return axios.get(`${baseUrl}/blob/${enc(did)}/${enc(cid)}`, {
201
- decompress: true,
202
- responseType: 'stream',
203
- timeout: 2000, // 2sec of inactivity on the connection
204
- })
205
- }
@@ -1,87 +1,83 @@
1
- import { Readable } from 'stream'
2
- import { pipeline } from 'stream/promises'
1
+ import { errHasMsg } from '@atproto/common'
2
+ import { PassThrough, Readable } from 'node:stream'
3
+ import { pipeline } from 'node:stream/promises'
3
4
  import sharp from 'sharp'
4
- import { errHasMsg, forwardStreamErrors } from '@atproto/common'
5
5
  import { formatsToMimes, ImageInfo, Options } from './util'
6
6
 
7
7
  export type { Options }
8
8
 
9
- export async function resize(
10
- stream: Readable,
11
- options: Options,
12
- ): Promise<Readable> {
13
- const { height, width, min = false, fit = 'cover', format, quality } = options
14
-
15
- let processor = sharp()
16
-
17
- // Scale up to hit any specified minimum size
18
- if (typeof min !== 'boolean') {
19
- const upsizeProcessor = sharp().resize({
20
- fit: 'outside',
21
- width: min.width,
22
- height: min.height,
23
- withoutReduction: true,
24
- withoutEnlargement: false,
25
- })
26
- forwardStreamErrors(stream, upsizeProcessor)
27
- stream = stream.pipe(upsizeProcessor)
28
- }
9
+ /**
10
+ * Scale up to hit any specified minimum size
11
+ */
12
+ export function createImageUpscaler({ min = false }: Options) {
13
+ // Due to the way sharp works, up-scaling must happen in a separate processor
14
+ // than down-scaling.
15
+ return typeof min !== 'boolean'
16
+ ? sharp().resize({
17
+ fit: 'outside',
18
+ width: min.width,
19
+ height: min.height,
20
+ withoutReduction: true,
21
+ withoutEnlargement: false,
22
+ })
23
+ : new PassThrough()
24
+ }
29
25
 
30
- // Scale down (or possibly up if min is true) to desired size
31
- processor = processor.resize({
26
+ /**
27
+ * Scale down (or possibly up if min is true) to desired size, then compress
28
+ * to the desired format.
29
+ */
30
+ export function createImageProcessor({
31
+ height,
32
+ width,
33
+ min = false,
34
+ fit = 'cover',
35
+ format,
36
+ quality = 100,
37
+ }: Options) {
38
+ const processor = sharp().resize({
32
39
  fit,
33
40
  width,
34
41
  height,
35
42
  withoutEnlargement: min !== true,
36
43
  })
37
44
 
38
- // Output to specified format
39
45
  if (format === 'jpeg') {
40
- processor = processor.jpeg({ quality: quality ?? 100 })
46
+ return processor.jpeg({ quality })
41
47
  } else if (format === 'png') {
42
- processor = processor.png({ quality: quality ?? 100 })
48
+ return processor.png({ quality })
43
49
  } else {
44
- const exhaustiveCheck: never = format
45
- throw new Error(`Unhandled case: ${exhaustiveCheck}`)
50
+ throw new Error(`Unhandled case: ${format}`)
46
51
  }
47
-
48
- forwardStreamErrors(stream, processor)
49
- return stream.pipe(processor)
50
52
  }
51
53
 
52
54
  export async function maybeGetInfo(
53
55
  stream: Readable,
54
56
  ): Promise<ImageInfo | null> {
55
- let metadata: sharp.Metadata
56
57
  try {
57
58
  const processor = sharp()
58
- const [result] = await Promise.all([
59
+
60
+ const [{ size, height, width, format }] = await Promise.all([
59
61
  processor.metadata(),
60
62
  pipeline(stream, processor), // Handles error propagation
61
63
  ])
62
- metadata = result
64
+
65
+ if (size == null || height == null || width == null || format == null) {
66
+ return null
67
+ }
68
+
69
+ return {
70
+ height,
71
+ width,
72
+ size,
73
+ mime: formatsToMimes.get(format) ?? 'unknown',
74
+ }
63
75
  } catch (err) {
64
76
  if (errHasMsg(err, 'Input buffer contains unsupported image format')) {
65
77
  return null
66
78
  }
67
79
  throw err
68
80
  }
69
- const { size, height, width, format } = metadata
70
- if (
71
- size === undefined ||
72
- height === undefined ||
73
- width === undefined ||
74
- format === undefined
75
- ) {
76
- return null
77
- }
78
-
79
- return {
80
- height,
81
- width,
82
- size,
83
- mime: formatsToMimes[format] ?? ('unknown' as const),
84
- }
85
81
  }
86
82
 
87
83
  export async function getInfo(stream: Readable): Promise<ImageInfo> {
package/src/image/util.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { FormatEnum } from 'sharp'
1
+ import { FormatEnum, OutputInfo } from 'sharp'
2
+
3
+ export type ImageMime = `image/${string}`
2
4
 
3
5
  export type Options = Dimensions & {
4
6
  format: 'jpeg' | 'png'
@@ -15,18 +17,24 @@ export type Options = Dimensions & {
15
17
 
16
18
  export type ImageInfo = Dimensions & {
17
19
  size: number
18
- mime: `image/${string}` | 'unknown'
20
+ mime: ImageMime | 'unknown'
19
21
  }
20
22
 
21
23
  export type Dimensions = { height: number; width: number }
22
24
 
23
- export const formatsToMimes: { [s in keyof FormatEnum]?: `image/${string}` } = {
24
- jpg: 'image/jpeg',
25
- jpeg: 'image/jpeg',
26
- png: 'image/png',
27
- gif: 'image/gif',
28
- svg: 'image/svg+xml',
29
- tif: 'image/tiff',
30
- tiff: 'image/tiff',
31
- webp: 'image/webp',
32
- }
25
+ export const formatsToMimes = new Map<keyof FormatEnum, ImageMime>([
26
+ ['jpg', 'image/jpeg'],
27
+ ['jpeg', 'image/jpeg'],
28
+ ['png', 'image/png'],
29
+ ['gif', 'image/gif'],
30
+ ['svg', 'image/svg+xml'],
31
+ ['tif', 'image/tiff'],
32
+ ['tiff', 'image/tiff'],
33
+ ['webp', 'image/webp'],
34
+ ['avif', 'image/avif'],
35
+ ['heif', 'image/heif'],
36
+ ['jp2', 'image/jp2'],
37
+ ['jxl', 'image/jxl'],
38
+ ])
39
+
40
+ export type SharpInfo = OutputInfo & { format: keyof FormatEnum }
package/src/index.ts CHANGED
@@ -8,15 +8,15 @@ import compression from 'compression'
8
8
  import { AtpAgent } from '@atproto/api'
9
9
  import { IdResolver } from '@atproto/identity'
10
10
  import { DAY, SECOND } from '@atproto/common'
11
+ import { Keypair } from '@atproto/crypto'
11
12
  import API, { health, wellKnown, blobResolver } from './api'
12
13
  import * as error from './error'
13
14
  import { loggerMiddleware } from './logger'
14
15
  import { ServerConfig } from './config'
15
16
  import { createServer } from './lexicon'
16
17
  import { ImageUriBuilder } from './image/uri'
17
- import { BlobDiskCache, ImageProcessingServer } from './image/server'
18
+ import * as imageServer from './image/server'
18
19
  import AppContext from './context'
19
- import { Keypair } from '@atproto/crypto'
20
20
  import { createDataPlaneClient } from './data-plane/client'
21
21
  import { Hydrator } from './hydration/hydrator'
22
22
  import { Views } from './views'
@@ -25,6 +25,7 @@ import { authWithApiKey as bsyncAuth, createBsyncClient } from './bsync'
25
25
  import { authWithApiKey as courierAuth, createCourierClient } from './courier'
26
26
  import { FeatureGates } from './feature-gates'
27
27
  import { VideoUriBuilder } from './views/util'
28
+ import { createBlobDispatcher } from './api/blob-dispatcher'
28
29
 
29
30
  export * from './data-plane'
30
31
  export type { ServerConfigValues } from './config'
@@ -73,15 +74,6 @@ export class BskyAppView {
73
74
  `${config.publicUrl}/vid/%s/%s/thumbnail.jpg`,
74
75
  })
75
76
 
76
- let imgProcessingServer: ImageProcessingServer | undefined
77
- if (!config.cdnUrl) {
78
- const imgProcessingCache = new BlobDiskCache(config.blobCacheLocation)
79
- imgProcessingServer = new ImageProcessingServer(
80
- config,
81
- imgProcessingCache,
82
- )
83
- }
84
-
85
77
  const searchAgent = config.searchUrl
86
78
  ? new AtpAgent({ service: config.searchUrl })
87
79
  : undefined
@@ -151,6 +143,8 @@ export class BskyAppView {
151
143
  env: config.statsigEnv,
152
144
  })
153
145
 
146
+ const blobDispatcher = createBlobDispatcher(config)
147
+
154
148
  const ctx = new AppContext({
155
149
  cfg: config,
156
150
  dataplane,
@@ -165,6 +159,7 @@ export class BskyAppView {
165
159
  courierClient,
166
160
  authVerifier,
167
161
  featureGates,
162
+ blobDispatcher,
168
163
  })
169
164
 
170
165
  let server = createServer({
@@ -180,10 +175,8 @@ export class BskyAppView {
180
175
 
181
176
  app.use(health.createRouter(ctx))
182
177
  app.use(wellKnown.createRouter(ctx))
183
- app.use(blobResolver.createRouter(ctx))
184
- if (imgProcessingServer) {
185
- app.use('/img', imgProcessingServer.app)
186
- }
178
+ app.use(blobResolver.createMiddleware(ctx))
179
+ app.use(imageServer.createMiddleware(ctx, { prefix: '/img/' }))
187
180
  app.use(server.xrpc.router)
188
181
  app.use(error.handler)
189
182
 
@@ -0,0 +1,41 @@
1
+ import createHttpError from 'http-errors'
2
+ import { IncomingMessage, ServerResponse } from 'node:http'
3
+ import { IncomingHttpHeaders } from 'undici/types/header'
4
+
5
+ type NextFunction = (err?: unknown) => void
6
+
7
+ export type Middleware = (
8
+ req: IncomingMessage,
9
+ res: ServerResponse,
10
+ next: NextFunction,
11
+ ) => void
12
+
13
+ export type ResponseData = { statusCode: number; headers: IncomingHttpHeaders }
14
+
15
+ const RESPONSE_HEADERS_TO_PROXY = new Set([
16
+ 'content-type',
17
+ 'content-length',
18
+ 'content-encoding',
19
+ 'content-language',
20
+ 'cache-control',
21
+ 'last-modified',
22
+ 'etag',
23
+ 'expires',
24
+ 'retry-after',
25
+ 'vary', // Might vary based on "accept" headers
26
+ ] as const satisfies (keyof IncomingHttpHeaders)[])
27
+
28
+ export function proxyResponseHeaders(data: ResponseData, res: ServerResponse) {
29
+ res.statusCode = data.statusCode >= 500 ? 502 : data.statusCode
30
+ for (const name of RESPONSE_HEADERS_TO_PROXY) {
31
+ const val = data.headers[name]
32
+ if (val) res.setHeader(name, val)
33
+ }
34
+ }
35
+
36
+ export function responseSignal(res: ServerResponse): AbortSignal {
37
+ if (res.destroyed) throw createHttpError(499, 'Client Disconnected')
38
+ const controller = new AbortController()
39
+ res.once('close', () => controller.abort())
40
+ return controller.signal
41
+ }
package/src/util/retry.ts CHANGED
@@ -1,38 +1,14 @@
1
- import { AxiosError } from 'axios'
2
- import { XRPCError, ResponseType } from '@atproto/xrpc'
3
- import { RetryOptions, retry } from '@atproto/common'
4
- import { Code, ConnectError } from '@connectrpc/connect'
1
+ import { createRetryable } from '@atproto/common'
2
+ import { ResponseType, XRPCError } from '@atproto/xrpc'
5
3
 
6
- export async function retryHttp<T>(
7
- fn: () => Promise<T>,
8
- opts: RetryOptions = {},
9
- ): Promise<T> {
10
- return retry(fn, { retryable: retryableHttp, ...opts })
11
- }
4
+ export const RETRYABLE_HTTP_STATUS_CODES = new Set([
5
+ 408, 425, 429, 500, 502, 503, 504, 522, 524,
6
+ ])
12
7
 
13
- export function retryableHttp(err: unknown) {
8
+ export const retryXrpc = createRetryable((err: unknown) => {
14
9
  if (err instanceof XRPCError) {
15
10
  if (err.status === ResponseType.Unknown) return true
16
- return retryableHttpStatusCodes.has(err.status)
17
- }
18
- if (err instanceof AxiosError) {
19
- if (!err.response) return true
20
- return retryableHttpStatusCodes.has(err.response.status)
11
+ return RETRYABLE_HTTP_STATUS_CODES.has(err.status)
21
12
  }
22
13
  return false
23
- }
24
-
25
- const retryableHttpStatusCodes = new Set([
26
- 408, 425, 429, 500, 502, 503, 504, 522, 524,
27
- ])
28
-
29
- export async function retryConnect<T>(
30
- fn: () => Promise<T>,
31
- opts: RetryOptions = {},
32
- ): Promise<T> {
33
- return retry(fn, { retryable: retryableConnect, ...opts })
34
- }
35
-
36
- export function retryableConnect(err: unknown) {
37
- return err instanceof ConnectError && err.code === Code.Unavailable
38
- }
14
+ })