@evanp/activitypub-bot 0.34.0 → 0.35.0

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/README.md CHANGED
@@ -58,6 +58,12 @@ Options:
58
58
  --port <number> Port to listen on
59
59
  --bots-config-file <path> Path to bots config module
60
60
  --log-level <level> Log level (e.g., info, debug)
61
+ --delivery <number> Number of background delivery workers
62
+ --distribution <number> Number of background distribution workers
63
+ --fanout <number> Number of background fanout workers
64
+ --intake <number> Number of background intake workers
65
+ --index-file <path> HTML page to show at root path
66
+ --profile-file <path> HTML page to show for bot profiles
61
67
  -h, --help Show this help
62
68
  ```
63
69
 
@@ -109,11 +115,29 @@ The number of background distribution workers to run for this server. These wor
109
115
 
110
116
  Falls back to the `DISTRIBUTION` environment variable. Default is 8.
111
117
 
118
+ #### --fanout
119
+
120
+ The number of background fanout workers to run for this server. These workers resolve the recipients for bot activities — expanding follower collections and other addressing — and enqueue individual delivery jobs. If activities are slow to reach recipients, increase this number.
121
+
122
+ Falls back to the `FANOUT` environment variable. Default is 4.
123
+
124
+ #### --intake
125
+
126
+ The number of background intake workers to run for this server. These workers accept activities arriving at the shared inbox and route them to the appropriate local bots. If shared-inbox processing is a bottleneck, increase this number.
127
+
128
+ Falls back to the `INTAKE` environment variable. Default is 2.
129
+
112
130
  #### --index-file
113
131
 
114
132
  Path to the HTML file to show for the home page of your server. The activitypub.bot server doesn't support any other files, so any images or CSS stylesheets or JavaScript in this page have to be hosted elsewhere. Or just skip them!
115
133
 
116
- Falls back to the 'INDEX_FILE' environment variable. The default index file is in `web/index.html` and just says that this is an activitypub.bot server with a link to the GitHub repo.
134
+ Falls back to the `INDEX_FILE` environment variable. The default index file is in `web/index.html` and just says that this is an activitypub.bot server with a link to the GitHub repo.
135
+
136
+ #### --profile-file
137
+
138
+ Path to the HTML file to show for bot profile pages. Like `--index-file`, any external assets must be hosted elsewhere.
139
+
140
+ Falls back to the `PROFILE_FILE` environment variable. The default profile file is in `web/profile.html`.
117
141
 
118
142
  ### Config file
119
143
 
@@ -184,9 +208,15 @@ Bots will receive a [BotContext](#botcontext) object at initialization. The BotC
184
208
 
185
209
  The Bot interface has the following methods.
186
210
 
187
- #### constructor (username)
211
+ #### constructor (username, options)
212
+
213
+ The constructor; receives the `username` and an optional `options` object. Initialization should probably be deferred to the `initialize()` method. The default implementation stores the username and the following options:
188
214
 
189
- The constructor; receives the `username` by default. Initialization should probably be deferred to the initialize() method. The default implementation stores the username.
215
+ - `fullname` display name for the bot. Default: `'Bot'`.
216
+ - `description` — bio text for the bot. Default: `'Default bot'`.
217
+ - `icon` — URL string for the bot's avatar image. Default: `null`.
218
+ - `image` — URL string for the bot's header/banner image. Default: `null`.
219
+ - `checkSignature` — whether to require valid HTTP signatures on incoming activities. Default: `true`.
190
220
 
191
221
  #### async initialize (context)
192
222
 
@@ -200,6 +230,18 @@ A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Fun
200
230
 
201
231
  A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the bio of the bot.
202
232
 
233
+ #### get icon ()
234
+
235
+ A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the avatar image URL of the bot. Returns `null` if not set.
236
+
237
+ #### get image ()
238
+
239
+ A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the header/banner image URL of the bot. Returns `null` if not set.
240
+
241
+ #### get checkSignature ()
242
+
243
+ A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for whether the bot requires valid HTTP signatures on incoming activities.
244
+
203
245
  #### get username ()
204
246
 
205
247
  A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the username of the bot. Should match the constructor argument.
@@ -14,6 +14,8 @@ const { values } = parseArgs({
14
14
  'log-level': { type: 'string' },
15
15
  delivery: { type: 'string' },
16
16
  distribution: { type: 'string' },
17
+ fanout: { type: 'string' },
18
+ intake: { type: 'string' },
17
19
  'index-file': { type: 'string' },
18
20
  help: { type: 'boolean', short: 'h' }
19
21
  },
@@ -31,6 +33,8 @@ Options:
31
33
  --log-level <level> Log level (e.g., info, debug)
32
34
  --delivery <number> Number of background delivery workers
33
35
  --distribution <number> Number of background distribution workers
36
+ --fanout <number> Number of background fanout workers
37
+ --intake <number> Number of background intake workers
34
38
  --index-file <path> HTML page to show at root path
35
39
  --profile-file <path> HTML page to show for bot profiles
36
40
  -h, --help Show this help
@@ -61,6 +65,8 @@ const LOG_LEVEL =
61
65
  (process.env.NODE_ENV === 'test' ? 'silent' : 'info')
62
66
  const DELIVERY = parseNumber(values.delivery) || parseNumber(process.env.DELIVERY) || 2
63
67
  const DISTRIBUTION = parseNumber(values.distribution) || parseNumber(process.env.DISTRIBUTION) || 8
68
+ const FANOUT = parseNumber(values.fanout) || parseNumber(process.env.FANOUT) || 4
69
+ const INTAKE = parseNumber(values.intake) || parseNumber(process.env.INTAKE) || 2
64
70
  const INDEX_FILE = values['index-file'] || process.env.INDEX_FILE || DEFAULT_INDEX_FILE
65
71
  const PROFILE_FILE = values['profile-file'] || process.env.PROFILE_FILE || DEFAULT_PROFILE_FILE
66
72
 
@@ -73,6 +79,8 @@ const app = await makeApp({
73
79
  logLevel: LOG_LEVEL,
74
80
  deliveryWorkerCount: DELIVERY,
75
81
  distributionWorkerCount: DISTRIBUTION,
82
+ fanoutWorkerCount: FANOUT,
83
+ intakeWorkerCount: INTAKE,
76
84
  indexFileName: INDEX_FILE,
77
85
  profileFileName: PROFILE_FILE
78
86
  })
@@ -23,6 +23,7 @@ const COLLECTION_TYPES = [
23
23
 
24
24
  export class ActivityDeliverer {
25
25
  static #QUEUE_ID = 'delivery'
26
+ static #INTAKE_QUEUE_ID = 'intake'
26
27
  static #TTL_SEEN_PUBLIC = 3 * 24 * 60 * 60 * 1000 // 3 days
27
28
  static #MAX_SEEN_PUBLIC = 1000000
28
29
  #actorStorage
@@ -106,10 +107,19 @@ export class ActivityDeliverer {
106
107
 
107
108
  async onIdle () {
108
109
  this.#logger.debug('Awaiting delivery queues')
110
+ await this.#jobQueue.onIdle(ActivityDeliverer.#INTAKE_QUEUE_ID)
109
111
  await this.#jobQueue.onIdle(ActivityDeliverer.#QUEUE_ID)
110
112
  this.#logger.debug('Done awaiting delivery queues')
111
113
  }
112
114
 
115
+ async intake (activity, subject) {
116
+ const raw = await activity.export({ useOriginalContext: true })
117
+ await this.#jobQueue.enqueue(
118
+ ActivityDeliverer.#INTAKE_QUEUE_ID,
119
+ { activity: raw, subject }
120
+ )
121
+ }
122
+
113
123
  async deliverToAll (activity, bots) {
114
124
  await this.#deliverToAll(activity, bots)
115
125
  }
@@ -14,6 +14,7 @@ const COLLECTION_TYPES = [
14
14
  export class ActivityDistributor {
15
15
  static #DISTRIBUTION_QUEUE_ID = 'distribution'
16
16
  static #DELIVERY_QUEUE_ID = 'delivery'
17
+ static #FANOUT_QUEUE_ID = 'fanout'
17
18
  static #MAX_CACHE_SIZE = 1000000
18
19
  static #PUBLIC = [
19
20
  'https://www.w3.org/ns/activitystreams#Public',
@@ -45,6 +46,14 @@ export class ActivityDistributor {
45
46
  }
46
47
 
47
48
  async distribute (activity, username) {
49
+ const raw = await activity.export({ useOriginalContext: true })
50
+ await this.#jobQueue.enqueue(
51
+ ActivityDistributor.#FANOUT_QUEUE_ID,
52
+ { activity: raw, username }
53
+ )
54
+ }
55
+
56
+ async fanout (activity, username) {
48
57
  const stripped = await this.#strip(activity)
49
58
  const raw = await stripped.export()
50
59
  const actorId = this.#formatter.format({ username })
@@ -102,8 +111,9 @@ export class ActivityDistributor {
102
111
  }
103
112
 
104
113
  async onIdle () {
105
- await this.#jobQueue.onIdle(ActivityDistributor.#DELIVERY_QUEUE_ID)
114
+ await this.#jobQueue.onIdle(ActivityDistributor.#FANOUT_QUEUE_ID)
106
115
  await this.#jobQueue.onIdle(ActivityDistributor.#DISTRIBUTION_QUEUE_ID)
116
+ await this.#jobQueue.onIdle(ActivityDistributor.#DELIVERY_QUEUE_ID)
107
117
  }
108
118
 
109
119
  async * #public (activity, username) {
@@ -311,14 +311,14 @@ export class ActivityHandler {
311
311
  actor: actor.id
312
312
  })
313
313
  await this.#actorStorage.addToCollection(bot.username, 'followers', actor)
314
- this.#logger.info(
315
- 'Sending accept',
316
- { actor: actor.id }
317
- )
318
314
  const followersId = this.#formatter.format({
319
315
  username: bot.username,
320
316
  collection: 'followers'
321
317
  })
318
+ this.#logger.debug(
319
+ 'Sending add activity for adding actor to followers',
320
+ { actor: actor.id, followers: followersId, follow: activity.id }
321
+ )
322
322
  const add = await this.#doActivity(bot, {
323
323
  type: 'Add',
324
324
  object: actor.id,
@@ -329,6 +329,10 @@ export class ActivityHandler {
329
329
  const recipients = await this.#getRecipients(activity)
330
330
  this.#addRecipient(recipients, actor, 'to')
331
331
  this.#removeRecipient(recipients, await this.#botActor(bot))
332
+ this.#logger.debug(
333
+ 'Sending accept activity for follow',
334
+ { bot: this.#botId(bot), actor: actor.id, follow: activity.id }
335
+ )
332
336
  const accept = await this.#doActivity(bot, {
333
337
  type: 'Accept',
334
338
  object: activity.id,
@@ -337,7 +341,9 @@ export class ActivityHandler {
337
341
  this.#logger.debug({ accept: await accept.export() }, 'accepted follow')
338
342
  this.#logger.debug({
339
343
  msg: 'Notifying bot of new follow',
340
- actor: actor.id
344
+ actor: actor.id,
345
+ bot: this.#botId(bot),
346
+ follow: activity.id
341
347
  })
342
348
  await bot.onFollow(actor, activity)
343
349
  }
@@ -6,7 +6,7 @@ const AS2_NS = 'https://www.w3.org/ns/activitystreams#'
6
6
  export class ActorStorage {
7
7
  #connection = null
8
8
  #formatter = null
9
- static #MAX_ITEMS_PER_PAGE = 20
9
+ static #MAX_ITEMS_PER_PAGE = 256
10
10
  constructor (connection, formatter) {
11
11
  this.#connection = connection
12
12
  this.#formatter = formatter
package/lib/app.js CHANGED
@@ -41,12 +41,20 @@ import { DeliveryWorker } from './deliveryworker.js'
41
41
  import { DistributionWorker } from './distributionworker.js'
42
42
  import { RateLimiter } from '../lib/ratelimiter.js'
43
43
  import DoNothingBot from './bots/donothing.js'
44
+ import { FanoutWorker } from './fanoutworker.js'
45
+ import { IntakeWorker } from './intakeworker.js'
44
46
 
45
47
  const currentDir = dirname(fileURLToPath(import.meta.url))
46
48
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
47
49
  const DEFAULT_PROFILE_FILENAME = resolve(currentDir, '..', 'web', 'profile.html')
48
50
 
49
- export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME }) {
51
+ function createWorkers (logger, count, WorkerClass, ...args) {
52
+ const workers = Array.from({ length: count }, () => new WorkerClass(...args))
53
+ const runs = workers.map(w => w.run().catch(err => logger.error({ err }, `unexpected error in ${WorkerClass.name}`)))
54
+ return { workers, runs }
55
+ }
56
+
57
+ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, fanoutWorkerCount = 4, intakeWorkerCount = 2, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME }) {
50
58
  const logger = Logger({
51
59
  level: logLevel
52
60
  })
@@ -136,35 +144,10 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
136
144
  logger
137
145
  ))
138
146
 
139
- const deliveryWorkers = new Array(deliveryWorkerCount)
140
- const deliveryWorkerRuns = new Array(deliveryWorkerCount)
141
-
142
- for (let i = 0; i < deliveryWorkers.length; i++) {
143
- deliveryWorkers[i] = new DeliveryWorker(
144
- jobQueue,
145
- actorStorage,
146
- activityHandler,
147
- logger,
148
- bots
149
- )
150
- deliveryWorkerRuns[i] = deliveryWorkers[i].run().catch((err) => {
151
- logger.error({ err, workerIndex: i }, 'unexpected error in delivery worker')
152
- })
153
- }
154
-
155
- const distributionWorkers = new Array(distributionWorkerCount)
156
- const distributionWorkerRuns = new Array(distributionWorkerCount)
157
-
158
- for (let i = 0; i < distributionWorkers.length; i++) {
159
- distributionWorkers[i] = new DistributionWorker(
160
- jobQueue,
161
- client,
162
- logger
163
- )
164
- distributionWorkerRuns[i] = distributionWorkers[i].run().catch((err) => {
165
- logger.error({ err, workerIndex: i }, 'unexpected error in distribution worker')
166
- })
167
- }
147
+ const { workers: deliveryWorkers, runs: deliveryWorkerRuns } = createWorkers(logger, deliveryWorkerCount, DeliveryWorker, jobQueue, logger, { actorStorage, activityHandler, bots })
148
+ const { workers: distributionWorkers, runs: distributionWorkerRuns } = createWorkers(logger, distributionWorkerCount, DistributionWorker, jobQueue, logger, { client })
149
+ const { workers: fanoutWorkers, runs: fanoutWorkerRuns } = createWorkers(logger, fanoutWorkerCount, FanoutWorker, jobQueue, logger, { distributor })
150
+ const { workers: intakeWorkers, runs: intakeWorkerRuns } = createWorkers(logger, intakeWorkerCount, IntakeWorker, jobQueue, logger, { deliverer, bots })
168
151
 
169
152
  const jobReaper = new JobReaper(jobQueue, logger)
170
153
  const jobReaperRun = jobReaper.run().catch((err) => {
@@ -273,23 +256,34 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
273
256
 
274
257
  app.onIdle = async () => {
275
258
  logger.debug('Awaiting components')
276
- await distributor.onIdle()
277
- await deliverer.onIdle()
259
+ await jobQueue.onIdle('intake')
260
+ await jobQueue.onIdle('delivery')
261
+ await jobQueue.onIdle('fanout')
262
+ await jobQueue.onIdle('delivery')
263
+ await jobQueue.onIdle('distribution')
278
264
  logger.debug('Done awaiting components')
279
265
  }
280
266
 
281
267
  app.cleanup = async () => {
282
268
  logger.info('Closing app')
283
- for (const worker of deliveryWorkers) {
269
+ for (const worker of intakeWorkers) {
270
+ worker.stop()
271
+ }
272
+ for (const worker of fanoutWorkers) {
284
273
  worker.stop()
285
274
  }
286
275
  for (const worker of distributionWorkers) {
287
276
  worker.stop()
288
277
  }
278
+ for (const worker of deliveryWorkers) {
279
+ worker.stop()
280
+ }
289
281
  jobReaper.stop()
290
282
  jobQueue.abort()
291
- await Promise.allSettled(deliveryWorkerRuns)
283
+ await Promise.allSettled(intakeWorkerRuns)
284
+ await Promise.allSettled(fanoutWorkerRuns)
292
285
  await Promise.allSettled(distributionWorkerRuns)
286
+ await Promise.allSettled(deliveryWorkerRuns)
293
287
  await jobReaperRun
294
288
  logger.info('Closing database connection')
295
289
  await connection.close()
@@ -1,71 +1,36 @@
1
1
  import assert from 'node:assert'
2
2
 
3
- import { nanoid } from 'nanoid'
4
-
5
3
  import as2 from './activitystreams.js'
6
4
  import BotMaker from './botmaker.js'
5
+ import { Worker } from './worker.js'
7
6
 
8
- export class DeliveryWorker {
9
- static #QUEUE_ID = 'delivery'
10
- #queue
7
+ export class DeliveryWorker extends Worker {
11
8
  #actorStorage
12
9
  #activityHandler
13
- #logger
14
- #running
15
- #workerId
16
10
  #bots
17
11
 
18
- constructor (queue, actorStorage, activityHandler, logger, bots) {
19
- this.#queue = queue
20
- this.#actorStorage = actorStorage
21
- this.#activityHandler = activityHandler
22
- this.#workerId = nanoid()
23
- this.#logger = logger.child({
24
- class: this.constructor.name,
25
- worker: this.#workerId
26
- })
27
- this.#bots = bots
12
+ constructor (jobQueue, logger, options = {}) {
13
+ super(jobQueue, logger, options)
14
+ assert.ok(options.actorStorage)
15
+ assert.ok(options.activityHandler)
16
+ assert.ok(options.bots)
17
+ this.#actorStorage = options.actorStorage
18
+ this.#activityHandler = options.activityHandler
19
+ this.#bots = options.bots
28
20
  }
29
21
 
30
- async run () {
31
- this.#running = true
32
- while (this.#running) {
33
- let jobId
34
- let payload
35
- try {
36
- this.#logger.debug('dequeueing');
37
- ({ jobId, payload } = await this.#queue.dequeue(
38
- DeliveryWorker.#QUEUE_ID,
39
- this.#workerId
40
- ))
41
- this.#logger.debug({ jobId, payload }, 'got a job')
42
- const activity = await as2.import(payload.activity)
43
- assert.ok(payload.botUsername)
44
- const bot = await BotMaker.makeBot(this.#bots, payload.botUsername)
45
- assert.ok(bot)
46
- assert.ok(bot.username)
47
- this.#logger.debug(
48
- { bot: bot.username, activity: activity.id, jobId },
49
- 'delivering to bot'
50
- )
51
- await this.#deliverTo(activity, bot)
52
- this.#logger.debug({ bot: bot.username, activity: activity.id, jobId }, 'done')
53
- await this.#queue.complete(jobId, this.#workerId)
54
- } catch (err) {
55
- if (err?.name === 'AbortError') {
56
- this.#logger.info('Worker received abort signal')
57
- break
58
- }
59
- this.#logger.warn({ err, jobId }, 'Error delivering to bot')
60
- if (jobId) {
61
- await this.#queue.release(jobId, this.#workerId)
62
- }
63
- }
64
- }
65
- }
66
-
67
- stop () {
68
- this.#running = false
22
+ async doJob (payload, attempts) {
23
+ const activity = await as2.import(payload.activity)
24
+ assert.ok(payload.botUsername)
25
+ const bot = await BotMaker.makeBot(this.#bots, payload.botUsername)
26
+ assert.ok(bot)
27
+ assert.ok(bot.username)
28
+ this._logger.debug(
29
+ { bot: bot.username, activity: activity.id },
30
+ 'delivering to bot'
31
+ )
32
+ await this.#deliverTo(activity, bot)
33
+ this._logger.debug({ bot: bot.username, activity: activity.id }, 'done')
69
34
  }
70
35
 
71
36
  async #deliverTo (activity, bot) {
@@ -75,7 +40,7 @@ export class DeliveryWorker {
75
40
  assert.strictEqual(typeof bot, 'object')
76
41
  assert.strictEqual(typeof bot.username, 'string')
77
42
  if (await this.#actorStorage.isInCollection(bot.username, 'inbox', activity)) {
78
- this.#logger.info(
43
+ this._logger.info(
79
44
  { activity: activity.id, username: bot.username },
80
45
  'skipping redelivery for activity already in the inbox'
81
46
  )
@@ -84,13 +49,13 @@ export class DeliveryWorker {
84
49
  try {
85
50
  await this.#activityHandler.handleActivity(bot, activity)
86
51
  } catch (err) {
87
- this.#logger.warn(
52
+ this._logger.warn(
88
53
  { err, activity: activity.id, bot: bot.username },
89
54
  'handler failed for activity'
90
55
  )
91
56
  }
92
- this.#logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
57
+ this._logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
93
58
  await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
94
- this.#logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
59
+ this._logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
95
60
  }
96
61
  }
@@ -1,123 +1,87 @@
1
- import { nanoid } from 'nanoid'
1
+ import assert from 'node:assert'
2
2
 
3
3
  import as2 from './activitystreams.js'
4
+ import { Worker, RecoverableError } from './worker.js'
4
5
 
5
- export class DistributionWorker {
6
- static #QUEUE_ID = 'distribution'
6
+ export class DistributionWorker extends Worker {
7
7
  static #MAX_ATTEMPTS = 21 // ~24 days
8
- #jobQueue
9
8
  #client
10
- #logger
11
- #running
12
- #workerId
13
9
 
14
- constructor (jobQueue, client, logger) {
15
- this.#jobQueue = jobQueue
16
- this.#client = client
17
- this.#workerId = nanoid()
18
- this.#logger = logger.child({
19
- class: this.constructor.name,
20
- workerId: this.#workerId
21
- })
10
+ constructor (jobQueue, logger, options = {}) {
11
+ super(jobQueue, logger, options)
12
+ assert.ok(options.client)
13
+ this.#client = options.client
22
14
  }
23
15
 
24
- async run () {
25
- this.#running = true
26
- while (this.#running) {
27
- let jobId
28
- let payload
29
- let attempts
30
- try {
31
- this.#logger.debug('dequeueing');
32
- ({ jobId, payload, attempts } = await this.#jobQueue.dequeue(
33
- DistributionWorker.#QUEUE_ID,
34
- this.#workerId
35
- ))
36
- this.#logger.debug({ jobId, payload, attempts }, 'got a job')
37
- const { inbox, activity, username } = payload
38
- const activityObj = await as2.import(activity)
39
- try {
40
- await this.#client.post(inbox, activityObj, username)
41
- this.#logger.info(`Delivered ${activity.id} to ${inbox}`)
42
- await this.#jobQueue.complete(jobId, this.#workerId)
43
- this.#logger.info({ jobId }, 'completed job')
44
- } catch (error) {
45
- if (!error.status) {
46
- this.#logger.warn(
47
- { error, activity: activity.id, inbox },
48
- 'Could not deliver activity and no HTTP status available')
49
- await this.#jobQueue.fail(jobId, this.#workerId)
50
- } else if (error.status >= 300 && error.status < 400) {
51
- this.#logger.warn(
52
- { error, activity: activity.id, inbox },
53
- 'Could not deliver activity and unexpected redirect code'
54
- )
55
- await this.#jobQueue.fail(jobId, this.#workerId)
56
- } else if (error.status >= 400 && error.status < 500) {
57
- this.#logger.warn(
58
- { error, activity: activity.id, inbox },
59
- 'Could not deliver activity due to client error'
60
- )
61
- if ([408, 425, 429].includes(error.status)) {
62
- this.#logger.debug(
63
- { error, activity: activity.id, inbox },
64
- 'Retrying on recoverable status'
65
- )
66
- const delay = this.#retryDelay(error.headers, attempts)
67
- await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
68
- } else {
69
- await this.#jobQueue.fail(jobId, this.#workerId)
70
- }
71
- } else if (error.status >= 500 && error.status < 600) {
72
- if ([501, 505, 508, 510].includes(error.status)) {
73
- this.#logger.warn(
74
- { error, activity: activity.id, inbox, attempts },
75
- 'Could not deliver activity due to unrecoverable server error'
76
- )
77
- await this.#jobQueue.fail(jobId, this.#workerId)
78
- } else if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
79
- this.#logger.warn(
80
- { error, activity: activity.id, inbox, attempts },
81
- 'Could not deliver activity due to server error; no more attempts'
82
- )
83
- await this.#jobQueue.fail(jobId, this.#workerId)
84
- } else {
85
- const delay = this.#retryDelay(error.headers, attempts)
86
- this.#logger.warn(
87
- { error, activity: activity.id, inbox, attempts, delay },
88
- 'Could not deliver activity due to server error; will retry'
89
- )
90
- await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
91
- }
92
- } else {
93
- this.#logger.warn(
94
- { error, activity: activity.id, inbox },
95
- 'Could not deliver activity due to unexpected status range'
96
- )
97
- await this.#jobQueue.fail(jobId, this.#workerId)
98
- }
99
- }
100
- } catch (err) {
101
- if (err?.name === 'AbortError') {
102
- this.#logger.info('Worker received abort signal')
103
- break
16
+ async doJob (payload, attempts) {
17
+ const { inbox, activity, username } = payload
18
+ const activityObj = await as2.import(activity)
19
+ try {
20
+ await this.#client.post(inbox, activityObj, username)
21
+ this._logger.info(`Delivered ${activity.id} to ${inbox}`)
22
+ } catch (error) {
23
+ if (!error.status) {
24
+ this._logger.warn(
25
+ { error, activity: activity.id, inbox },
26
+ 'Could not deliver activity and no HTTP status available')
27
+ throw error
28
+ } else if (error.status >= 300 && error.status < 400) {
29
+ this._logger.warn(
30
+ { error, activity: activity.id, inbox },
31
+ 'Could not deliver activity and unexpected redirect code'
32
+ )
33
+ throw error
34
+ } else if (error.status >= 400 && error.status < 500) {
35
+ this._logger.warn(
36
+ { error, activity: activity.id, inbox },
37
+ 'Could not deliver activity due to client error'
38
+ )
39
+ if ([408, 425, 429].includes(error.status)) {
40
+ this._logger.debug(
41
+ { error, activity: activity.id, inbox },
42
+ 'Retrying on recoverable status'
43
+ )
44
+ const recoverable = new RecoverableError(error.message)
45
+ recoverable.delay = this.#retryDelay(error.headers, attempts)
46
+ throw recoverable
47
+ } else {
48
+ throw error
104
49
  }
105
- this.#logger.warn({ err, jobId }, 'Error delivering to bot')
106
- if (jobId) {
107
- const delay = this.#retryDelay(null, attempts ?? 1)
108
- this.#logger.warn(
109
- { err, jobId, attempts, delay },
110
- 'Retrying job after a delay'
50
+ } else if (error.status >= 500 && error.status < 600) {
51
+ if ([501, 505, 508, 510].includes(error.status)) {
52
+ this._logger.warn(
53
+ { error, activity: activity.id, inbox, attempts },
54
+ 'Could not deliver activity due to unrecoverable server error'
55
+ )
56
+ throw error
57
+ } else if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
58
+ this._logger.warn(
59
+ { error, activity: activity.id, inbox, attempts },
60
+ 'Could not deliver activity due to server error; no more attempts'
61
+ )
62
+ throw error
63
+ } else {
64
+ const recoverable = new RecoverableError(error.message)
65
+ recoverable.delay = this.#retryDelay(error.headers, attempts)
66
+ this._logger.warn(
67
+ { error, activity: activity.id, inbox, attempts, delay: recoverable.delay },
68
+ 'Could not deliver activity due to server error; will retry'
111
69
  )
112
- await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
70
+ throw recoverable
113
71
  }
72
+ } else {
73
+ this._logger.warn(
74
+ { error, activity: activity.id, inbox },
75
+ 'Could not deliver activity due to unexpected status range'
76
+ )
77
+ throw error
114
78
  }
115
79
  }
116
80
  }
117
81
 
118
82
  #retryDelay (headers, attempts) {
119
83
  if (headers?.['retry-after']) {
120
- this.#logger.debug('using retry-after header')
84
+ this._logger.debug('using retry-after header')
121
85
  const retryAfter = headers['retry-after']
122
86
  if (/^\d+$/.test(retryAfter)) {
123
87
  return parseInt(retryAfter, 10) * 1000
@@ -125,11 +89,7 @@ export class DistributionWorker {
125
89
  return new Date(retryAfter) - Date.now()
126
90
  }
127
91
  }
128
- this.#logger.debug('exponential backoff')
92
+ this._logger.debug('exponential backoff')
129
93
  return Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
130
94
  }
131
-
132
- stop () {
133
- this.#running = false
134
- }
135
95
  }
@@ -0,0 +1,20 @@
1
+ import assert from 'node:assert'
2
+
3
+ import as2 from './activitystreams.js'
4
+ import { Worker } from './worker.js'
5
+
6
+ export class FanoutWorker extends Worker {
7
+ #distributor
8
+
9
+ constructor (jobQueue, logger, options = {}) {
10
+ super(jobQueue, logger, options)
11
+ assert.ok(options.distributor)
12
+ this.#distributor = options.distributor
13
+ }
14
+
15
+ async doJob (payload, attempts) {
16
+ const activity = await as2.import(payload.activity)
17
+ const { username } = payload
18
+ await this.#distributor.fanout(activity, username)
19
+ }
20
+ }
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert'
2
+
3
+ import as2 from './activitystreams.js'
4
+ import { Worker } from './worker.js'
5
+
6
+ export class IntakeWorker extends Worker {
7
+ #deliverer
8
+ #bots
9
+ constructor (jobQueue, logger, options = {}) {
10
+ super(jobQueue, logger, options)
11
+ assert.ok(options.deliverer)
12
+ assert.ok(options.bots)
13
+ this.#deliverer = options.deliverer
14
+ this.#bots = options.bots
15
+ }
16
+
17
+ async doJob (payload, attempts) {
18
+ const raw = payload.activity
19
+ const activity = await as2.import(raw)
20
+ await this.#deliverer.deliverToAll(activity, this.#bots)
21
+ }
22
+ }
@@ -11,7 +11,7 @@ export class NoSuchObjectError extends Error {
11
11
 
12
12
  export class ObjectStorage {
13
13
  #connection = null
14
- static #MAX_ITEMS_PER_PAGE = 20
14
+ static #MAX_ITEMS_PER_PAGE = 256
15
15
 
16
16
  constructor (connection) {
17
17
  this.#connection = connection
@@ -69,7 +69,7 @@ router.post('/shared/inbox', async (req, res, next) => {
69
69
 
70
70
  logger.info(`Activity ${activity.id} received at shared inbox`)
71
71
 
72
- await deliverer.deliverToAll(activity, bots)
72
+ await deliverer.intake(activity, subject)
73
73
 
74
74
  res.status(202)
75
75
  res.type('text/plain')
package/lib/worker.js ADDED
@@ -0,0 +1,86 @@
1
+ import assert from 'node:assert'
2
+ import { nanoid } from 'nanoid'
3
+
4
+ export class RecoverableError extends Error {
5
+ delay = 1000
6
+ }
7
+
8
+ export class Worker {
9
+ #jobQueue
10
+ #logger
11
+ #running
12
+ #workerId
13
+
14
+ constructor (jobQueue, logger, options = {}) {
15
+ this.#jobQueue = jobQueue
16
+ this.#workerId = nanoid()
17
+ this.#logger = logger.child({
18
+ class: this.constructor.name,
19
+ worker: this.#workerId
20
+ })
21
+ }
22
+
23
+ get _logger () {
24
+ return this.#logger
25
+ }
26
+
27
+ get queueId () {
28
+ const name = this.constructor.name
29
+ return name.slice(0, name.length - 6).toLowerCase()
30
+ }
31
+
32
+ async run () {
33
+ this.#running = true
34
+ while (this.#running) {
35
+ let jobId
36
+ let payload
37
+ let attempts
38
+ try {
39
+ this.#logger.debug('dequeueing');
40
+ ({ jobId, payload, attempts } = await this.#jobQueue.dequeue(
41
+ this.queueId,
42
+ this.#workerId
43
+ ))
44
+ this.#logger.debug({ jobId, payload, attempts }, 'got a job')
45
+ } catch (err) {
46
+ if (err?.name === 'AbortError') {
47
+ this.#logger.info('Worker received abort signal')
48
+ break
49
+ } else {
50
+ this.#logger.warn({ err, jobId }, 'error before job, retrying')
51
+ if (jobId) {
52
+ const delay = this.#retryDelay(attempts ?? 1)
53
+ await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
54
+ }
55
+ continue
56
+ }
57
+ }
58
+
59
+ assert.ok(jobId)
60
+ assert.ok(payload)
61
+
62
+ try {
63
+ await this.doJob(payload, attempts)
64
+ await this.#jobQueue.complete(jobId, this.#workerId)
65
+ } catch (err) {
66
+ if (err instanceof RecoverableError) {
67
+ await this.#jobQueue.retryAfter(jobId, this.#workerId, err.delay)
68
+ } else {
69
+ await this.#jobQueue.fail(jobId, this.#workerId)
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ async doJob (payload, attempts) {
76
+ throw new Error('Must implement doJob')
77
+ }
78
+
79
+ stop () {
80
+ this.#running = false
81
+ }
82
+
83
+ #retryDelay (attempts) {
84
+ return Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
85
+ }
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",