@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.
- package/CHANGELOG.md +31 -0
- package/dist/api/blob-dispatcher.d.ts +4 -0
- package/dist/api/blob-dispatcher.d.ts.map +1 -0
- package/dist/api/blob-dispatcher.js +37 -0
- package/dist/api/blob-dispatcher.js.map +1 -0
- package/dist/api/blob-resolver.d.ts +17 -8
- package/dist/api/blob-resolver.d.ts.map +1 -1
- package/dist/api/blob-resolver.js +246 -99
- package/dist/api/blob-resolver.js.map +1 -1
- package/dist/api/well-known.d.ts.map +1 -1
- package/dist/api/well-known.js +30 -24
- package/dist/api/well-known.js.map +1 -1
- package/dist/config.d.ts +14 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +40 -5
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +3 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -0
- package/dist/context.js.map +1 -1
- package/dist/data-plane/server/indexing/index.js +2 -2
- package/dist/image/server.d.ts +7 -13
- package/dist/image/server.d.ts.map +1 -1
- package/dist/image/server.js +119 -115
- package/dist/image/server.js.map +1 -1
- package/dist/image/sharp.d.ts +11 -2
- package/dist/image/sharp.d.ts.map +1 -1
- package/dist/image/sharp.js +35 -38
- package/dist/image/sharp.js.map +1 -1
- package/dist/image/util.d.ts +6 -4
- package/dist/image/util.d.ts.map +1 -1
- package/dist/image/util.js +14 -10
- package/dist/image/util.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -10
- package/dist/index.js.map +1 -1
- package/dist/util/http.d.ts +12 -0
- package/dist/util/http.d.ts.map +1 -0
- package/dist/util/http.js +36 -0
- package/dist/util/http.js.map +1 -0
- package/dist/util/retry.d.ts +2 -5
- package/dist/util/retry.d.ts.map +1 -1
- package/dist/util/retry.js +8 -27
- package/dist/util/retry.js.map +1 -1
- package/package.json +18 -14
- package/src/api/blob-dispatcher.ts +38 -0
- package/src/api/blob-resolver.ts +341 -106
- package/src/api/well-known.ts +31 -24
- package/src/config.ts +63 -6
- package/src/context.ts +6 -0
- package/src/data-plane/server/indexing/index.ts +3 -3
- package/src/image/server.ts +131 -107
- package/src/image/sharp.ts +48 -52
- package/src/image/util.ts +20 -12
- package/src/index.ts +8 -15
- package/src/util/http.ts +41 -0
- package/src/util/retry.ts +8 -32
- package/tests/_util.ts +50 -3
- package/tests/blob-resolver.test.ts +62 -36
- package/tests/image/server.test.ts +40 -32
- package/tests/image/sharp.test.ts +17 -4
- package/tests/label-hydration.test.ts +6 -6
- package/tests/server.test.ts +41 -56
- 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 {
|
|
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
|
|
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
|
|
290
|
+
await retryXrpc(() => api.com.atproto.sync.getLatestCommit({ did }))
|
|
291
291
|
return true
|
|
292
292
|
} catch (err) {
|
|
293
293
|
if (err instanceof ComAtprotoSyncGetLatestCommit.RepoNotFoundError) {
|
package/src/image/server.ts
CHANGED
|
@@ -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
|
-
|
|
3
|
+
createDecoders,
|
|
19
4
|
isErrnoException,
|
|
5
|
+
VerifyCidError,
|
|
6
|
+
VerifyCidTransform,
|
|
20
7
|
} from '@atproto/common'
|
|
21
|
-
import {
|
|
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 {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
return
|
|
61
|
+
await pipeline(cachedImage, res)
|
|
62
|
+
return
|
|
62
63
|
} catch (err) {
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
package/src/image/sharp.ts
CHANGED
|
@@ -1,87 +1,83 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
46
|
+
return processor.jpeg({ quality })
|
|
41
47
|
} else if (format === 'png') {
|
|
42
|
-
|
|
48
|
+
return processor.png({ quality })
|
|
43
49
|
} else {
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
20
|
+
mime: ImageMime | 'unknown'
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export type Dimensions = { height: number; width: number }
|
|
22
24
|
|
|
23
|
-
export const formatsToMimes
|
|
24
|
-
jpg
|
|
25
|
-
jpeg
|
|
26
|
-
png
|
|
27
|
-
gif
|
|
28
|
-
svg
|
|
29
|
-
tif
|
|
30
|
-
tiff
|
|
31
|
-
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
|
|
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.
|
|
184
|
-
|
|
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
|
|
package/src/util/http.ts
ADDED
|
@@ -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 {
|
|
2
|
-
import {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
8
|
+
export const retryXrpc = createRetryable((err: unknown) => {
|
|
14
9
|
if (err instanceof XRPCError) {
|
|
15
10
|
if (err.status === ResponseType.Unknown) return true
|
|
16
|
-
return
|
|
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
|
+
})
|