Skip to content

Commit

Permalink
Add support for a bunny CDN img invalidator (bluesky-social#1689)
Browse files Browse the repository at this point in the history
* add support for a bunny.net img invalidator

* tidy

* support multiple image invalidators

* tidy
  • Loading branch information
devinivy authored Oct 5, 2023
1 parent 8853bc1 commit 88a23d4
Show file tree
Hide file tree
Showing 11 changed files with 137 additions and 31 deletions.
1 change: 1 addition & 0 deletions packages/aws/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"update-main-to-dist": "node ../../update-main-to-dist.js packages/aws"
},
"dependencies": {
"@atproto/common": "workspace:^",
"@atproto/crypto": "workspace:^",
"@atproto/repo": "workspace:^",
"@aws-sdk/client-cloudfront": "^3.261.0",
Expand Down
36 changes: 36 additions & 0 deletions packages/aws/src/bunny.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { handleAllSettledErrors } from '@atproto/common'
import { ImageInvalidator } from './types'

export type BunnyConfig = {
accessKey: string
urlPrefix: string
}

const API_PURGE_URL = 'https://api.bunny.net/purge'

export class BunnyInvalidator implements ImageInvalidator {
constructor(public cfg: BunnyConfig) {}
async invalidate(_subject: string, paths: string[]) {
const results = await Promise.allSettled(
paths.map(async (path) =>
purgeUrl({
url: this.cfg.urlPrefix + path,
accessKey: this.cfg.accessKey,
}),
),
)
handleAllSettledErrors(results)
}
}

export default BunnyInvalidator

async function purgeUrl(opts: { accessKey: string; url: string }) {
const search = new URLSearchParams()
search.set('async', 'true')
search.set('url', opts.url)
await fetch(API_PURGE_URL + '?' + search.toString(), {
method: 'post',
headers: { AccessKey: opts.accessKey },
})
}
7 changes: 1 addition & 6 deletions packages/aws/src/cloudfront.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as aws from '@aws-sdk/client-cloudfront'
import { ImageInvalidator } from './types'

export type CloudfrontConfig = {
distributionId: string
Expand Down Expand Up @@ -33,9 +34,3 @@ export class CloudfrontInvalidator implements ImageInvalidator {
}

export default CloudfrontInvalidator

// @NOTE keep in sync with same interface in pds/src/image/invalidator.ts
// this is separate to avoid the dependency on @atproto/pds.
interface ImageInvalidator {
invalidate(subject: string, paths: string[]): Promise<void>
}
3 changes: 3 additions & 0 deletions packages/aws/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export * from './kms'
export * from './s3'
export * from './cloudfront'
export * from './bunny'
export * from './util'
export * from './types'
5 changes: 5 additions & 0 deletions packages/aws/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @NOTE keep in sync with same interface in bsky/src/image/invalidator.ts
// this is separate to avoid the dependency on @atproto/bsky.
export interface ImageInvalidator {
invalidate(subject: string, paths: string[]): Promise<void>
}
14 changes: 14 additions & 0 deletions packages/aws/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { handleAllSettledErrors } from '@atproto/common'
import { ImageInvalidator } from './types'

export class MultiImageInvalidator implements ImageInvalidator {
constructor(public invalidators: ImageInvalidator[]) {}
async invalidate(subject: string, paths: string[]) {
const results = await Promise.allSettled(
this.invalidators.map((invalidator) =>
invalidator.invalidate(subject, paths),
),
)
handleAllSettledErrors(results)
}
}
22 changes: 1 addition & 21 deletions packages/bsky/src/indexer/subscription.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert'
import { CID } from 'multiformats/cid'
import { AtUri } from '@atproto/syntax'
import { cborDecode, wait } from '@atproto/common'
import { cborDecode, wait, handleAllSettledErrors } from '@atproto/common'
import { DisconnectError } from '@atproto/xrpc-server'
import {
WriteOpAction,
Expand Down Expand Up @@ -343,23 +343,3 @@ type PreparedDelete = {
}

type PreparedWrite = PreparedCreate | PreparedUpdate | PreparedDelete

function handleAllSettledErrors(results: PromiseSettledResult<unknown>[]) {
const errors = results.filter(isRejected).map((res) => res.reason)
if (errors.length === 0) {
return
}
if (errors.length === 1) {
throw errors[0]
}
throw new AggregateError(
errors,
'Multiple errors: ' + errors.map((err) => err?.message).join('\n'),
)
}

function isRejected(
result: PromiseSettledResult<unknown>,
): result is PromiseRejectedResult {
return result.status === 'rejected'
}
22 changes: 22 additions & 0 deletions packages/common-web/src/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,25 @@ export class AsyncBufferFullError extends Error {
super(`ReachedMaxBufferSize: ${maxSize}`)
}
}

export const handleAllSettledErrors = (
results: PromiseSettledResult<unknown>[],
) => {
const errors = results.filter(isRejected).map((res) => res.reason)
if (errors.length === 0) {
return
}
if (errors.length === 1) {
throw errors[0]
}
throw new AggregateError(
errors,
'Multiple errors: ' + errors.map((err) => err?.message).join('\n'),
)
}

const isRejected = (
result: PromiseSettledResult<unknown>,
): result is PromiseRejectedResult => {
return result.status === 'rejected'
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 28 additions & 2 deletions services/bsky/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ require('dd-trace') // Only works with commonjs
// Tracer code above must come before anything else
const path = require('path')
const assert = require('assert')
const { CloudfrontInvalidator } = require('@atproto/aws')
const {
BunnyInvalidator,
CloudfrontInvalidator,
MultiImageInvalidator,
} = require('@atproto/aws')
const {
DatabaseCoordinator,
PrimaryDatabase,
Expand Down Expand Up @@ -59,17 +63,38 @@ const main = async () => {
imgUriEndpoint: env.imgUriEndpoint,
blobCacheLocation: env.blobCacheLocation,
})

// configure zero, one, or both image invalidators
let imgInvalidator
const bunnyInvalidator = env.bunnyAccessKey
? new BunnyInvalidator({
accessKey: env.bunnyAccessKey,
urlPrefix: cfg.imgUriEndpoint,
})
: undefined
const cfInvalidator = env.cfDistributionId
? new CloudfrontInvalidator({
distributionId: env.cfDistributionId,
pathPrefix: cfg.imgUriEndpoint && new URL(cfg.imgUriEndpoint).pathname,
})
: undefined

if (bunnyInvalidator && imgInvalidator) {
imgInvalidator = new MultiImageInvalidator([
bunnyInvalidator,
imgInvalidator,
])
} else if (bunnyInvalidator) {
imgInvalidator = bunnyInvalidator
} else if (cfInvalidator) {
imgInvalidator = cfInvalidator
}

const algos = env.feedPublisherDid ? makeAlgos(env.feedPublisherDid) : {}
const bsky = BskyAppView.create({
db,
config: cfg,
imgInvalidator: cfInvalidator,
imgInvalidator,
algos,
})
// separate db needed for more permissions
Expand Down Expand Up @@ -127,6 +152,7 @@ const getEnv = () => ({
imgUriKey: process.env.IMG_URI_KEY,
imgUriEndpoint: process.env.IMG_URI_ENDPOINT,
blobCacheLocation: process.env.BLOB_CACHE_LOC,
bunnyAccessKey: process.env.BUNNY_ACCESS_KEY,
cfDistributionId: process.env.CF_DISTRIBUTION_ID,
feedPublisherDid: process.env.FEED_PUBLISHER_DID,
})
Expand Down
25 changes: 23 additions & 2 deletions services/bsky/indexer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require('dd-trace/init') // Only works with commonjs

// Tracer code above must come before anything else
const { CloudfrontInvalidator } = require('@atproto/aws')
const { CloudfrontInvalidator, BunnyInvalidator } = require('@atproto/aws')
const {
IndexerConfig,
BskyIndexer,
Expand All @@ -25,12 +25,32 @@ const main = async () => {
dbPostgresUrl: env.dbPostgresUrl,
dbPostgresSchema: env.dbPostgresSchema,
})

// configure zero, one, or both image invalidators
let imgInvalidator
const bunnyInvalidator = env.bunnyAccessKey
? new BunnyInvalidator({
accessKey: env.bunnyAccessKey,
urlPrefix: cfg.imgUriEndpoint,
})
: undefined
const cfInvalidator = env.cfDistributionId
? new CloudfrontInvalidator({
distributionId: env.cfDistributionId,
pathPrefix: cfg.imgUriEndpoint && new URL(cfg.imgUriEndpoint).pathname,
})
: undefined
if (bunnyInvalidator && imgInvalidator) {
imgInvalidator = new MultiImageInvalidator([
bunnyInvalidator,
imgInvalidator,
])
} else if (bunnyInvalidator) {
imgInvalidator = bunnyInvalidator
} else if (cfInvalidator) {
imgInvalidator = cfInvalidator
}

const redis = new Redis(
cfg.redisSentinelName
? {
Expand All @@ -47,7 +67,7 @@ const main = async () => {
db,
redis,
cfg,
imgInvalidator: cfInvalidator,
imgInvalidator,
})
await indexer.start()
process.on('SIGTERM', async () => {
Expand Down Expand Up @@ -77,6 +97,7 @@ const getEnv = () => ({
dbPoolSize: maybeParseInt(process.env.DB_POOL_SIZE),
dbPoolMaxUses: maybeParseInt(process.env.DB_POOL_MAX_USES),
dbPoolIdleTimeoutMs: maybeParseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS),
bunnyAccessKey: process.env.BUNNY_ACCESS_KEY,
cfDistributionId: process.env.CF_DISTRIBUTION_ID,
imgUriEndpoint: process.env.IMG_URI_ENDPOINT,
})
Expand Down

0 comments on commit 88a23d4

Please sign in to comment.