@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 +45 -3
- package/bin/activitypub-bot.js +8 -0
- package/lib/activitydeliverer.js +10 -0
- package/lib/activitydistributor.js +11 -1
- package/lib/activityhandler.js +11 -5
- package/lib/activitypubclient.js +81 -29
- package/lib/actorstorage.js +1 -1
- package/lib/app.js +31 -35
- package/lib/deliveryworker.js +26 -61
- package/lib/distributionworker.js +68 -108
- package/lib/fanoutworker.js +20 -0
- package/lib/intakeworker.js +22 -0
- package/lib/migrations/007-remote-object-cache.js +20 -0
- package/lib/objectstorage.js +1 -1
- package/lib/remoteobjectcache.js +152 -0
- package/lib/routes/sharedinbox.js +1 -1
- package/lib/worker.js +86 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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.
|
package/bin/activitypub-bot.js
CHANGED
|
@@ -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
|
})
|
package/lib/activitydeliverer.js
CHANGED
|
@@ -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.#
|
|
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) {
|
package/lib/activityhandler.js
CHANGED
|
@@ -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
|
}
|
package/lib/activitypubclient.js
CHANGED
|
@@ -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
|
-
|
|
62
|
-
return await this.#
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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 #
|
|
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 ${
|
|
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 =
|
|
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 ${
|
|
97
|
-
const
|
|
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 }, '
|
|
104
|
-
await this.#limiter.update(hostname,
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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(
|
|
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 ${
|
|
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 = (
|
|
191
|
+
const resolved = (parsed.hash)
|
|
140
192
|
? this.#resolveObject(obj, url)
|
|
141
193
|
: obj
|
|
142
194
|
return resolved
|
package/lib/actorstorage.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
277
|
-
await
|
|
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
|
|
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(
|
|
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()
|
package/lib/deliveryworker.js
CHANGED
|
@@ -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 (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
this.#
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
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
|
|
57
|
+
this._logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
|
|
93
58
|
await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
|
|
94
|
-
this
|
|
59
|
+
this._logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
|
|
95
60
|
}
|
|
96
61
|
}
|
|
@@ -1,123 +1,87 @@
|
|
|
1
|
-
import
|
|
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,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
this.#
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/objectstorage.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|