@evanp/activitypub-bot 0.34.1 → 0.36.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
  }
@@ -36,20 +36,23 @@ export class ActivityPubClient {
36
36
  #digester = null
37
37
  #logger = null
38
38
  #limiter
39
+ #cache
39
40
 
40
- constructor (keyStorage, urlFormatter, signer, digester, logger, limiter) {
41
+ constructor (keyStorage, urlFormatter, signer, digester, logger, limiter, cache) {
41
42
  assert.strictEqual(typeof keyStorage, 'object')
42
43
  assert.strictEqual(typeof urlFormatter, 'object')
43
44
  assert.strictEqual(typeof signer, 'object')
44
45
  assert.strictEqual(typeof digester, 'object')
45
46
  assert.strictEqual(typeof logger, 'object')
46
47
  assert.strictEqual(typeof limiter, 'object')
48
+ assert.strictEqual(typeof cache, 'object')
47
49
  this.#keyStorage = keyStorage
48
50
  this.#urlFormatter = urlFormatter
49
51
  this.#signer = signer
50
52
  this.#digester = digester
51
53
  this.#logger = logger.child({ class: this.constructor.name })
52
54
  this.#limiter = limiter
55
+ this.#cache = cache
53
56
  }
54
57
 
55
58
  async get (url, username = this.#urlFormatter.hostname) {
@@ -58,85 +61,134 @@ export class ActivityPubClient {
58
61
  assert.ok(username)
59
62
  assert.equal(typeof username, 'string')
60
63
  assert.ok(username !== '*')
61
- const res = await this.#getRes(url, username, true)
62
- return await this.#handleRes(res, url)
64
+
65
+ return await this.#get(url, username, true)
63
66
  }
64
67
 
65
68
  async getKey (url) {
66
69
  assert.ok(url)
67
70
  assert.equal(typeof url, 'string')
68
- let res = await this.#getRes(url, this.#urlFormatter.hostname, false)
69
- if ([401, 403, 404].includes(res.status)) {
70
- // If we get a 401, 403, or 404, we should try again with the key
71
- res = await this.#getRes(url, this.#urlFormatter.hostname, true)
71
+ let obj
72
+ try {
73
+ obj = await this.#get(url, this.#urlFormatter.hostname, false, false)
74
+ } catch (err) {
75
+ if (err.status && [401, 403, 404].includes(err.status)) {
76
+ obj = await this.#get(url, this.#urlFormatter.hostname, true, false)
77
+ } else {
78
+ throw err
79
+ }
72
80
  }
73
- return await this.#handleRes(res, url)
81
+ return obj
82
+ }
83
+
84
+ async #get (url, username = this.#urlFormatter.hostname, sign = false, useCache = true) {
85
+ const json = await this.#getJSON(url, username, sign, useCache)
86
+ const obj = await this.#getObj(url, json)
87
+ return obj
74
88
  }
75
89
 
76
- async #getRes (url, username = this.#urlFormatter.hostname, sign = false) {
90
+ async #getJSON (url, username = this.#urlFormatter.hostname, sign = false, useCache = true) {
77
91
  assert.ok(url)
78
92
  assert.equal(typeof url, 'string')
79
93
  assert.ok(username)
80
94
  assert.equal(typeof username, 'string')
95
+
96
+ const parsed = new URL(url)
97
+ const baseUrl = `${parsed.origin}${parsed.pathname}${parsed.search}`
98
+ let cached
99
+
100
+ if (useCache) {
101
+ cached = await this.#cache.get(baseUrl, username)
102
+ this.#logger.debug({ baseUrl, username, cached: !!cached, expiry: cached?.expiry }, 'cache lookup')
103
+
104
+ if (cached) {
105
+ this.#logger.debug({ baseUrl }, 'cache hit')
106
+ const now = Date.now()
107
+ const expiry = cached.expiry.getTime()
108
+ if (expiry > now) {
109
+ this.#logger.debug({ baseUrl, expiry, now }, 'cache fresh, returning cached object')
110
+ return cached.object
111
+ } else {
112
+ this.#logger.debug({ baseUrl, expiry, now }, 'cache stale, revalidating')
113
+ }
114
+ }
115
+ }
116
+
81
117
  const date = new Date().toUTCString()
82
118
  const headers = {
83
119
  accept: ActivityPubClient.#accept,
84
120
  date,
85
- 'user-agent': ActivityPubClient.#userAgent
121
+ 'user-agent': ActivityPubClient.#userAgent,
122
+ 'if-modified-since': cached?.lastModified?.toUTCString(),
123
+ 'if-none-match': cached?.etag
86
124
  }
125
+ this.#logger.debug({ headers }, 'Sending headers')
87
126
  const method = 'GET'
88
- this.#logger.debug(`Signing GET request for ${url}`)
127
+ this.#logger.debug(`Signing GET request for ${baseUrl}`)
89
128
  if (sign) {
90
129
  headers.signature =
91
- await this.#sign({ username, url, method, headers })
130
+ await this.#sign({ username, url: baseUrl, method, headers })
92
131
  }
93
- const hostname = (new URL(url)).hostname
94
- this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
132
+ const hostname = parsed.hostname
133
+ this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for rate limiter')
95
134
  await this.#limiter.limit(hostname)
96
- this.#logger.debug(`Fetching ${url} with GET`)
97
- const result = await fetch(url,
135
+ this.#logger.debug(`Fetching ${baseUrl} with GET`)
136
+ const res = await fetch(baseUrl,
98
137
  {
99
138
  method,
100
139
  headers
101
140
  }
102
141
  )
103
- this.#logger.debug({ hostname }, 'updating limiter')
104
- await this.#limiter.update(hostname, result.headers)
142
+ this.#logger.debug({ hostname, status: res.status }, 'response received')
143
+ await this.#limiter.update(hostname, res.headers)
105
144
  this.#logger.debug(`Finished getting ${url}`)
106
- return result
107
- }
108
-
109
- async #handleRes (res, url) {
110
- if (res.status < 200 || res.status > 299) {
145
+ if (useCache && res.status === 304) {
146
+ this.#logger.debug({ baseUrl }, '304 Not Modified, returning cached object')
147
+ return cached.object
148
+ } else if (res.status < 200 || res.status > 299) {
111
149
  const body = await res.text()
112
- this.#logger.warn({ status: res.status, body, url }, 'Could not fetch url')
150
+ this.#logger.warn(
151
+ { status: res.status, body, url: baseUrl },
152
+ 'Could not fetch url'
153
+ )
113
154
  throw createHttpError(
114
155
  res.status,
115
- `Could not fetch ${url}`,
156
+ `Could not fetch ${baseUrl}`,
116
157
  { headers: res.headers }
117
158
  )
118
159
  }
160
+
119
161
  const contentType = res.headers.get('content-type')
120
162
  const mimeType = contentType?.split(';')[0].trim()
121
163
  if (mimeType !== 'application/json' && !mimeType.endsWith('+json')) {
122
- this.#logger.warn({ mimeType, url }, 'Unexpected mime type')
164
+ this.#logger.warn({ mimeType, url: baseUrl }, 'Unexpected mime type')
123
165
  throw new Error(`Got unexpected mime type ${mimeType} for URL ${url}`)
124
166
  }
125
167
  let json
126
168
  try {
127
169
  json = await res.json()
128
170
  } catch (err) {
129
- this.#logger.warn({ url }, 'Error parsing fetch results')
171
+ this.#logger.warn({ url: baseUrl }, 'Error parsing fetch results')
130
172
  throw err
131
173
  }
174
+
175
+ await this.#cache.set(baseUrl, username, json, res.headers)
176
+
177
+ return json
178
+ }
179
+
180
+ async #getObj (url, json) {
181
+ const parsed = new URL(url)
182
+ const baseUrl = `${parsed.origin}${parsed.pathname}${parsed.search}`
183
+
132
184
  let obj
133
185
  try {
134
186
  obj = await as2.import(json)
135
187
  } catch (err) {
136
- this.#logger.warn({ url, json }, 'Error importing JSON as AS2')
188
+ this.#logger.warn({ url: baseUrl, json }, 'Error importing JSON as AS2')
137
189
  throw err
138
190
  }
139
- const resolved = (URL.parse(url).hash)
191
+ const resolved = (parsed.hash)
140
192
  ? this.#resolveObject(obj, url)
141
193
  : obj
142
194
  return resolved
@@ -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,21 @@ 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'
46
+ import { RemoteObjectCache } from './remoteobjectcache.js'
44
47
 
45
48
  const currentDir = dirname(fileURLToPath(import.meta.url))
46
49
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
47
50
  const DEFAULT_PROFILE_FILENAME = resolve(currentDir, '..', 'web', 'profile.html')
48
51
 
49
- export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME }) {
52
+ function createWorkers (logger, count, WorkerClass, ...args) {
53
+ const workers = Array.from({ length: count }, () => new WorkerClass(...args))
54
+ const runs = workers.map(w => w.run().catch(err => logger.error({ err }, `unexpected error in ${WorkerClass.name}`)))
55
+ return { workers, runs }
56
+ }
57
+
58
+ 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
59
  const logger = Logger({
51
60
  level: logLevel
52
61
  })
@@ -63,7 +72,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
63
72
  const keyStorage = new KeyStorage(connection, logger)
64
73
  const objectStorage = new ObjectStorage(connection)
65
74
  const limiter = new RateLimiter(connection, logger)
66
- const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter)
75
+ const remoteObjectCache = new RemoteObjectCache(connection, logger)
76
+ const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter, remoteObjectCache)
67
77
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
68
78
  const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
69
79
  const jobQueue = new JobQueue(connection, logger)
@@ -136,35 +146,10 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
136
146
  logger
137
147
  ))
138
148
 
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
- }
149
+ const { workers: deliveryWorkers, runs: deliveryWorkerRuns } = createWorkers(logger, deliveryWorkerCount, DeliveryWorker, jobQueue, logger, { actorStorage, activityHandler, bots })
150
+ const { workers: distributionWorkers, runs: distributionWorkerRuns } = createWorkers(logger, distributionWorkerCount, DistributionWorker, jobQueue, logger, { client })
151
+ const { workers: fanoutWorkers, runs: fanoutWorkerRuns } = createWorkers(logger, fanoutWorkerCount, FanoutWorker, jobQueue, logger, { distributor })
152
+ const { workers: intakeWorkers, runs: intakeWorkerRuns } = createWorkers(logger, intakeWorkerCount, IntakeWorker, jobQueue, logger, { deliverer, bots })
168
153
 
169
154
  const jobReaper = new JobReaper(jobQueue, logger)
170
155
  const jobReaperRun = jobReaper.run().catch((err) => {
@@ -273,23 +258,34 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
273
258
 
274
259
  app.onIdle = async () => {
275
260
  logger.debug('Awaiting components')
276
- await distributor.onIdle()
277
- await deliverer.onIdle()
261
+ await jobQueue.onIdle('intake')
262
+ await jobQueue.onIdle('delivery')
263
+ await jobQueue.onIdle('fanout')
264
+ await jobQueue.onIdle('delivery')
265
+ await jobQueue.onIdle('distribution')
278
266
  logger.debug('Done awaiting components')
279
267
  }
280
268
 
281
269
  app.cleanup = async () => {
282
270
  logger.info('Closing app')
283
- for (const worker of deliveryWorkers) {
271
+ for (const worker of intakeWorkers) {
272
+ worker.stop()
273
+ }
274
+ for (const worker of fanoutWorkers) {
284
275
  worker.stop()
285
276
  }
286
277
  for (const worker of distributionWorkers) {
287
278
  worker.stop()
288
279
  }
280
+ for (const worker of deliveryWorkers) {
281
+ worker.stop()
282
+ }
289
283
  jobReaper.stop()
290
284
  jobQueue.abort()
291
- await Promise.allSettled(deliveryWorkerRuns)
285
+ await Promise.allSettled(intakeWorkerRuns)
286
+ await Promise.allSettled(fanoutWorkerRuns)
292
287
  await Promise.allSettled(distributionWorkerRuns)
288
+ await Promise.allSettled(deliveryWorkerRuns)
293
289
  await jobReaperRun
294
290
  logger.info('Closing database connection')
295
291
  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
+ }
@@ -0,0 +1,20 @@
1
+ export const id = '007-remote-object-cache'
2
+
3
+ export async function up (connection, queryOptions = {}) {
4
+ await connection.query(`
5
+ CREATE TABLE remote_object_cache (
6
+ id varchar(512) NOT NULL,
7
+ username varchar(512) NOT NULL,
8
+ last_modified TIMESTAMP NULL,
9
+ etag varchar(256) NULL,
10
+ expiry TIMESTAMP NULL,
11
+ data TEXT NOT NULL,
12
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+ PRIMARY KEY (id, username)
15
+ );
16
+ `, queryOptions)
17
+ await connection.query(`
18
+ CREATE INDEX remote_object_cache_expiry on remote_object_cache (expiry);
19
+ `, queryOptions)
20
+ }
@@ -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
@@ -0,0 +1,152 @@
1
+ import assert from 'node:assert'
2
+
3
+ import as2 from './activitystreams.js'
4
+
5
+ const SEC_NS = 'https://w3id.org/security#'
6
+ const NS = 'https://www.w3.org/ns/activitystreams#'
7
+ const KEY_TYPES = [SEC_NS + 'Key', SEC_NS + 'CryptographicKey']
8
+ const COLLECTION_TYPES =
9
+ [NS + 'Collection', NS + 'CollectionPage', NS + 'OrderedCollection', NS + 'OrderedCollectionPage']
10
+
11
+ export class RemoteObjectCache {
12
+ #connection
13
+ #logger
14
+ constructor (connection, logger) {
15
+ assert.ok(connection)
16
+ assert.ok(logger)
17
+ this.#connection = connection
18
+ this.#logger = logger
19
+ }
20
+
21
+ async get (id, username) {
22
+ assert.ok(id)
23
+ assert.strictEqual(typeof id, 'string')
24
+ assert.ok(username)
25
+ assert.strictEqual(typeof username, 'string')
26
+
27
+ let result
28
+
29
+ const [rows] = await this.#connection.query(
30
+ `SELECT last_modified, etag, expiry, data
31
+ FROM remote_object_cache
32
+ WHERE id = ? AND username = ?`,
33
+ { replacements: [id, username] })
34
+
35
+ if (rows.length === 0) {
36
+ result = null
37
+ } else {
38
+ result = {
39
+ expiry: new Date(rows[0].expiry),
40
+ lastModified: rows[0].last_modified
41
+ ? new Date(rows[0].last_modified)
42
+ : null,
43
+ etag: rows[0].etag,
44
+ object: JSON.parse(rows[0].data)
45
+ }
46
+ }
47
+
48
+ assert.ok(
49
+ result === null ||
50
+ (
51
+ typeof result === 'object' &&
52
+ 'expiry' in result &&
53
+ 'lastModified' in result &&
54
+ 'etag' in result &&
55
+ 'object' in result
56
+ )
57
+ )
58
+
59
+ return result
60
+ }
61
+
62
+ async set (id, username, object, headers) {
63
+ assert.ok(id)
64
+ assert.strictEqual(typeof id, 'string')
65
+ assert.ok(username)
66
+ assert.strictEqual(typeof username, 'string')
67
+ assert.ok(object)
68
+ assert.strictEqual(typeof object, 'object')
69
+ assert.ok(headers)
70
+ assert.strictEqual(typeof headers, 'object')
71
+ assert.ok(typeof headers.get === 'function')
72
+
73
+ const cacheControl = headers.get('cache-control')
74
+ if (cacheControl && cacheControl.includes('no-store')) {
75
+ return
76
+ }
77
+
78
+ const expiry = await this.#getExpiry(object, headers)
79
+ const lastModified = headers.get('last-modified')
80
+ const etag = headers.get('etag')
81
+
82
+ await this.#connection.query(
83
+ `INSERT INTO remote_object_cache
84
+ (id, username, last_modified, etag, expiry, data)
85
+ VALUES (?, ?, ?, ?, ?, ?)
86
+ ON CONFLICT (id, username)
87
+ DO UPDATE
88
+ SET last_modified = EXCLUDED.last_modified,
89
+ etag = EXCLUDED.etag,
90
+ expiry = EXCLUDED.expiry,
91
+ data = EXCLUDED.data,
92
+ updated_at = CURRENT_TIMESTAMP
93
+ `,
94
+ { replacements: [id, username, lastModified, etag, expiry, JSON.stringify(object)] }
95
+ )
96
+ }
97
+
98
+ async #getExpiry (object, headers) {
99
+ assert.ok(object)
100
+ assert.strictEqual(typeof object, 'object')
101
+ assert.ok(headers)
102
+ assert.strictEqual(typeof headers, 'object')
103
+ assert.ok(typeof headers.get === 'function')
104
+
105
+ let expiry = this.#getExpiryFromHeaders(headers)
106
+ if (!expiry) {
107
+ expiry = await this.#getExpiryFromObject(object)
108
+ }
109
+ return expiry
110
+ }
111
+
112
+ #getExpiryFromHeaders (headers) {
113
+ assert.ok(headers)
114
+ assert.strictEqual(typeof headers, 'object')
115
+ assert.ok(typeof headers.get === 'function')
116
+
117
+ const cacheControl = headers.get('cache-control')
118
+ if (cacheControl) {
119
+ if (cacheControl.includes('no-cache')) {
120
+ return new Date(Date.now() - 1)
121
+ } else if (cacheControl.includes('max-age')) {
122
+ const match = cacheControl.match(/max-age=(\d+)/)
123
+ if (match) {
124
+ return new Date(Date.now() + parseInt(match[1], 10) * 1000)
125
+ }
126
+ }
127
+ }
128
+ if (headers.has('expires')) {
129
+ return new Date(headers.get('expires'))
130
+ }
131
+ return null
132
+ }
133
+
134
+ async #getExpiryFromObject (object) {
135
+ assert.ok(object)
136
+ assert.strictEqual(typeof object, 'object')
137
+ const as2obj = await as2.import(object)
138
+ let offset
139
+ if (as2obj.isActivity()) {
140
+ offset = 24 * 60 * 60 * 1000
141
+ } else if (as2obj.inbox) {
142
+ offset = 30 * 60 * 1000
143
+ } else if (KEY_TYPES.includes(as2obj.type)) {
144
+ offset = -1000
145
+ } else if (COLLECTION_TYPES.includes(as2obj.type)) {
146
+ offset = -1000
147
+ } else {
148
+ offset = 5 * 60 * 1000
149
+ }
150
+ return new Date(Date.now() + offset)
151
+ }
152
+ }
@@ -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.1",
3
+ "version": "0.36.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",