@fedify/backfill 2.3.0-dev.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/dist/mod.js ADDED
@@ -0,0 +1,364 @@
1
+ import { Activity, Collection, CollectionPage, Create, Object as Object$1, OrderedCollection, OrderedCollectionPage } from "@fedify/vocab";
2
+ //#region src/backfill.ts
3
+ const defaultStrategies = ["context-auto"];
4
+ const DEFAULT_MAX_DEPTH = 10;
5
+ /**
6
+ * Thrown when backfill traversal exceeds the configured request budget.
7
+ *
8
+ * @since 2.3.0
9
+ */
10
+ var MaxRequestsExceeded = class extends Error {};
11
+ /**
12
+ * Backfills post-like objects related to a seed object.
13
+ *
14
+ * The seed object is not yielded by default, but its ID is treated as already
15
+ * seen so it will not be yielded again if the collection contains it.
16
+ *
17
+ * @since 2.3.0
18
+ */
19
+ async function* backfill(context, note, options = {}) {
20
+ if (options.maxItems != null && options.maxItems <= 0) return;
21
+ const strategies = normalizeStrategies(options.strategies);
22
+ if (strategies.length < 1) return;
23
+ const budget = {
24
+ signal: options.signal,
25
+ requestCount: 0,
26
+ documents: /* @__PURE__ */ new Map()
27
+ };
28
+ const seenIds = /* @__PURE__ */ new Set();
29
+ if (note.id != null) seenIds.add(note.id.href);
30
+ let yielded = 0;
31
+ try {
32
+ for (let i = 0; i < strategies.length; i++) {
33
+ const strategy = strategies[i];
34
+ let items;
35
+ if (isContextStrategy(strategy)) {
36
+ const contextStrategies = [strategy];
37
+ while (true) {
38
+ const nextStrategy = strategies[i + 1];
39
+ if (nextStrategy == null || !isContextStrategy(nextStrategy)) break;
40
+ contextStrategies.push(nextStrategy);
41
+ i++;
42
+ }
43
+ items = getContextStrategyItems(context, note, contextStrategies, options, budget, seenIds);
44
+ } else items = getStrategyItems(context, note, strategy, options, budget, seenIds);
45
+ for await (const item of items) {
46
+ const id = item.object.id ?? void 0;
47
+ if (id != null) {
48
+ if (seenIds.has(id.href)) continue;
49
+ seenIds.add(id.href);
50
+ }
51
+ options.signal?.throwIfAborted();
52
+ yield {
53
+ object: item.object,
54
+ id,
55
+ strategy: item.strategy,
56
+ origin: item.origin,
57
+ depth: item.depth
58
+ };
59
+ yielded++;
60
+ if (options.maxItems != null && yielded >= options.maxItems) return;
61
+ }
62
+ }
63
+ } catch (error) {
64
+ if (error instanceof MaxRequestsExceeded) return;
65
+ throw error;
66
+ }
67
+ }
68
+ function normalizeStrategies(strategies = defaultStrategies) {
69
+ const normalized = [];
70
+ for (const strategy of strategies) if (strategy === "context-auto") {
71
+ for (let i = normalized.length - 1; i >= 0 && isContextStrategy(normalized[i]); i--) normalized.splice(i, 1);
72
+ if (!normalized.includes(strategy)) normalized.push(strategy);
73
+ } else if (isContextStrategy(strategy)) {
74
+ if (!currentContextGroupHasAuto(normalized) && !normalized.includes(strategy)) normalized.push(strategy);
75
+ } else if (!normalized.includes(strategy)) normalized.push(strategy);
76
+ return normalized;
77
+ }
78
+ function isContextStrategy(strategy) {
79
+ return strategy === "context-objects" || strategy === "context-activities" || strategy === "context-auto";
80
+ }
81
+ function currentContextGroupHasAuto(strategies) {
82
+ for (let i = strategies.length - 1; i >= 0; i--) {
83
+ const strategy = strategies[i];
84
+ if (!isContextStrategy(strategy)) return false;
85
+ if (strategy === "context-auto") return true;
86
+ }
87
+ return false;
88
+ }
89
+ async function* getContextStrategyItems(context, note, strategies, options, budget, seenIds) {
90
+ const contextId = note.contextIds[0];
91
+ if (contextId == null) return;
92
+ const collection = await loadObject(context, contextId, options, budget);
93
+ if (!isCollection(collection)) return;
94
+ for await (const object of getCollectionItems(context, collection, options, budget, seenIds)) for (const strategy of strategies) for await (const item of getContextBackfillItems(context, object, strategy, options, budget)) yield {
95
+ object: item.object,
96
+ strategy: item.strategy,
97
+ origin: "collection",
98
+ depth: 0
99
+ };
100
+ }
101
+ async function* getStrategyItems(context, note, strategy, options, budget, seenIds) {
102
+ if (isContextStrategy(strategy)) yield* getContextStrategyItems(context, note, [strategy], options, budget, seenIds);
103
+ else if (strategy === "reply-tree") yield* getReplyTreeItems(context, note, options, budget);
104
+ }
105
+ async function* getReplyTreeItems(context, note, options, budget) {
106
+ const visitedObjectIds = /* @__PURE__ */ new Set();
107
+ const visitedObjects = /* @__PURE__ */ new WeakSet();
108
+ const visitedCollectionIds = /* @__PURE__ */ new Set();
109
+ const visitedCollections = /* @__PURE__ */ new WeakSet();
110
+ if (note.id != null) visitedObjectIds.add(note.id.href);
111
+ visitedObjects.add(note);
112
+ const ancestors = [];
113
+ for await (const item of getReplyAncestors(context, note, options, budget, {
114
+ depth: 1,
115
+ visitedObjectIds,
116
+ visitedObjects,
117
+ visitedCollectionIds,
118
+ visitedCollections
119
+ })) {
120
+ ancestors.push({
121
+ object: item.object,
122
+ depth: item.depth
123
+ });
124
+ yield item;
125
+ }
126
+ for (const ancestor of ancestors.toReversed()) yield* getReplyDescendants(context, ancestor.object, options, budget, {
127
+ depth: ancestor.depth + 1,
128
+ visitedObjectIds,
129
+ visitedObjects,
130
+ visitedCollectionIds,
131
+ visitedCollections
132
+ });
133
+ yield* getReplyDescendants(context, note, options, budget, {
134
+ depth: 1,
135
+ visitedObjectIds,
136
+ visitedObjects,
137
+ visitedCollectionIds,
138
+ visitedCollections
139
+ });
140
+ }
141
+ async function* getReplyAncestors(context, object, options, budget, traversal) {
142
+ if (traversal.depth > (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return;
143
+ for await (const target of getReplyTargets(context, object, options, budget)) {
144
+ if (!isContextPostObject(target)) continue;
145
+ if (!visitReplyTreeObject(target, traversal)) continue;
146
+ yield {
147
+ object: target,
148
+ strategy: "reply-tree",
149
+ origin: "in-reply-to",
150
+ depth: traversal.depth
151
+ };
152
+ yield* getReplyAncestors(context, target, options, budget, {
153
+ depth: traversal.depth + 1,
154
+ visitedObjectIds: traversal.visitedObjectIds,
155
+ visitedObjects: traversal.visitedObjects,
156
+ visitedCollectionIds: traversal.visitedCollectionIds,
157
+ visitedCollections: traversal.visitedCollections
158
+ });
159
+ }
160
+ }
161
+ async function* getReplyDescendants(context, object, options, budget, traversal) {
162
+ if (traversal.depth > (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return;
163
+ const repliesId = object.repliesId;
164
+ if (repliesId != null && traversal.visitedCollectionIds.has(repliesId.href)) return;
165
+ const replies = await getRepliesCollection(context, object, options, budget);
166
+ if (replies == null) return;
167
+ const unvisited = visitReplyTreeCollection(replies, traversal);
168
+ if (repliesId != null) traversal.visitedCollectionIds.add(repliesId.href);
169
+ if (!unvisited) return;
170
+ for await (const reply of getCollectionItems(context, replies, options, budget, traversal.visitedObjectIds)) {
171
+ if (!isContextPostObject(reply)) continue;
172
+ if (!visitReplyTreeObject(reply, traversal)) continue;
173
+ yield {
174
+ object: reply,
175
+ strategy: "reply-tree",
176
+ origin: "replies",
177
+ depth: traversal.depth
178
+ };
179
+ yield* getReplyDescendants(context, reply, options, budget, {
180
+ depth: traversal.depth + 1,
181
+ visitedObjectIds: traversal.visitedObjectIds,
182
+ visitedObjects: traversal.visitedObjects,
183
+ visitedCollectionIds: traversal.visitedCollectionIds,
184
+ visitedCollections: traversal.visitedCollections
185
+ });
186
+ }
187
+ }
188
+ async function* getReplyTargets(context, object, options, budget) {
189
+ try {
190
+ yield* object.getReplyTargets({
191
+ documentLoader: async (url) => {
192
+ return await loadCollectionItemDocument(context, url, options, budget);
193
+ },
194
+ crossOrigin: "trust"
195
+ });
196
+ } catch (error) {
197
+ if (error instanceof MaxRequestsExceeded) throw error;
198
+ budget.signal?.throwIfAborted();
199
+ }
200
+ }
201
+ async function getRepliesCollection(context, object, options, budget) {
202
+ try {
203
+ return await object.getReplies({
204
+ documentLoader: async (url) => {
205
+ return await loadCollectionItemDocument(context, url, options, budget);
206
+ },
207
+ crossOrigin: "trust"
208
+ });
209
+ } catch (error) {
210
+ if (error instanceof MaxRequestsExceeded) throw error;
211
+ budget.signal?.throwIfAborted();
212
+ return null;
213
+ }
214
+ }
215
+ function visitReplyTreeObject(object, traversal) {
216
+ if (object.id != null) {
217
+ if (traversal.visitedObjectIds.has(object.id.href)) return false;
218
+ traversal.visitedObjectIds.add(object.id.href);
219
+ } else if (traversal.visitedObjects.has(object)) return false;
220
+ traversal.visitedObjects.add(object);
221
+ return true;
222
+ }
223
+ function visitReplyTreeCollection(collection, traversal) {
224
+ if (collection.id != null) return visitReplyTreeCollectionId(collection.id, traversal);
225
+ else if (traversal.visitedCollections.has(collection)) return false;
226
+ traversal.visitedCollections.add(collection);
227
+ return true;
228
+ }
229
+ function visitReplyTreeCollectionId(id, traversal) {
230
+ if (traversal.visitedCollectionIds.has(id.href)) return false;
231
+ traversal.visitedCollectionIds.add(id.href);
232
+ return true;
233
+ }
234
+ async function* getContextBackfillItems(context, object, strategy, options, budget) {
235
+ if (strategy === "context-objects" && isContextPostObject(object)) yield {
236
+ object,
237
+ strategy
238
+ };
239
+ else if (strategy === "context-activities") {
240
+ const activityObject = await getCreateActivityObject(context, object, options, budget);
241
+ if (activityObject != null && isContextPostObject(activityObject)) yield {
242
+ object: activityObject,
243
+ strategy
244
+ };
245
+ } else if (strategy === "context-auto") {
246
+ if (object instanceof Activity) {
247
+ const activityObject = await getCreateActivityObject(context, object, options, budget);
248
+ if (activityObject != null && isContextPostObject(activityObject)) yield {
249
+ object: activityObject,
250
+ strategy
251
+ };
252
+ } else if (isContextPostObject(object)) yield {
253
+ object,
254
+ strategy
255
+ };
256
+ }
257
+ }
258
+ async function* getCollectionItems(context, collection, options, budget, skipIds) {
259
+ yield* collection.getItems({
260
+ documentLoader: async (url) => {
261
+ return await loadCollectionItemDocument(context, url, options, budget, skipIds);
262
+ },
263
+ crossOrigin: "trust"
264
+ });
265
+ }
266
+ async function getCreateActivityObject(context, object, options, budget) {
267
+ if (!(object instanceof Create)) return null;
268
+ try {
269
+ return await object.getObject({
270
+ documentLoader: async (url) => {
271
+ return await loadCollectionItemDocument(context, url, options, budget);
272
+ },
273
+ crossOrigin: "trust"
274
+ });
275
+ } catch (error) {
276
+ if (error instanceof MaxRequestsExceeded) throw error;
277
+ budget.signal?.throwIfAborted();
278
+ return null;
279
+ }
280
+ }
281
+ async function loadCollectionItemDocument(context, url, options, budget, skipIds) {
282
+ let object;
283
+ try {
284
+ const iri = new URL(url);
285
+ if (skipIds?.has(iri.href)) return skippedCollectionItemDocument(url);
286
+ object = await loadObject(context, iri, options, budget, true);
287
+ } catch (error) {
288
+ if (error instanceof MaxRequestsExceeded) throw error;
289
+ budget.signal?.throwIfAborted();
290
+ return skippedCollectionItemDocument(url);
291
+ }
292
+ if (object == null) return skippedCollectionItemDocument(url);
293
+ return {
294
+ contextUrl: null,
295
+ documentUrl: url,
296
+ document: await object.toJsonLd()
297
+ };
298
+ }
299
+ function skippedCollectionItemDocument(url) {
300
+ return {
301
+ contextUrl: null,
302
+ documentUrl: url,
303
+ document: {
304
+ "@context": "https://www.w3.org/ns/activitystreams",
305
+ type: "Activity"
306
+ }
307
+ };
308
+ }
309
+ async function loadObject(context, iri, options, budget, throwOnBudgetExceeded = false) {
310
+ budget.signal?.throwIfAborted();
311
+ const cacheKey = iri.href;
312
+ const cached = budget.documents.get(cacheKey);
313
+ if (cached != null) return await cached;
314
+ if (options.maxRequests != null && budget.requestCount >= options.maxRequests) {
315
+ if (throwOnBudgetExceeded) throw new MaxRequestsExceeded();
316
+ return null;
317
+ }
318
+ await waitForInterval(options, budget);
319
+ budget.signal?.throwIfAborted();
320
+ budget.requestCount++;
321
+ const document = context.documentLoader(iri, { signal: budget.signal });
322
+ budget.documents.set(cacheKey, document);
323
+ try {
324
+ return await document;
325
+ } catch (error) {
326
+ if (budget.documents.get(cacheKey) === document) budget.documents.delete(cacheKey);
327
+ throw error;
328
+ }
329
+ }
330
+ async function waitForInterval(options, budget) {
331
+ if (options.interval == null) return;
332
+ const milliseconds = durationToMilliseconds(typeof options.interval === "function" ? options.interval(budget.requestCount) : options.interval);
333
+ if (milliseconds <= 0) return;
334
+ await new Promise((resolve, reject) => {
335
+ if (budget.signal?.aborted) {
336
+ reject(budget.signal.reason);
337
+ return;
338
+ }
339
+ const timeout = setTimeout(() => {
340
+ budget.signal?.removeEventListener("abort", onAbort);
341
+ resolve();
342
+ }, milliseconds);
343
+ const onAbort = () => {
344
+ clearTimeout(timeout);
345
+ reject(budget.signal?.reason);
346
+ };
347
+ budget.signal?.addEventListener("abort", onAbort, { once: true });
348
+ });
349
+ }
350
+ function durationToMilliseconds(duration) {
351
+ if (typeof duration === "string") {
352
+ if (typeof Temporal === "undefined") throw new TypeError("Temporal is not globally available; pass interval as a Temporal.DurationLike object instead of a string, or provide a Temporal polyfill.");
353
+ return Temporal.Duration.from(duration).total({ unit: "milliseconds" });
354
+ }
355
+ return (duration.milliseconds ?? 0) + (duration.seconds ?? 0) * 1e3 + (duration.minutes ?? 0) * 60 * 1e3 + (duration.hours ?? 0) * 60 * 60 * 1e3 + (duration.days ?? 0) * 24 * 60 * 60 * 1e3;
356
+ }
357
+ function isCollection(object) {
358
+ return object instanceof Collection || object instanceof OrderedCollection || object instanceof CollectionPage || object instanceof OrderedCollectionPage;
359
+ }
360
+ function isContextPostObject(object) {
361
+ return object instanceof Object$1 && !(object instanceof Activity) && !isCollection(object);
362
+ }
363
+ //#endregion
364
+ export { MaxRequestsExceeded, backfill };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@fedify/backfill",
3
+ "version": "2.3.0-dev.0",
4
+ "description": "ActivityPub backfill support for Fedify",
5
+ "keywords": [
6
+ "Fedify",
7
+ "ActivityPub",
8
+ "Fediverse",
9
+ "Backfill"
10
+ ],
11
+ "author": {
12
+ "name": "Jiwon Kwon",
13
+ "email": "work@kwonjiwon.org"
14
+ },
15
+ "homepage": "https://fedify.dev/",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/fedify-dev/fedify.git",
19
+ "directory": "packages/backfill"
20
+ },
21
+ "license": "MIT",
22
+ "bugs": {
23
+ "url": "https://github.com/fedify-dev/fedify/issues"
24
+ },
25
+ "funding": [
26
+ "https://opencollective.com/fedify",
27
+ "https://github.com/sponsors/dahlia"
28
+ ],
29
+ "type": "module",
30
+ "main": "./dist/mod.cjs",
31
+ "module": "./dist/mod.js",
32
+ "types": "./dist/mod.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": {
36
+ "import": "./dist/mod.d.ts",
37
+ "require": "./dist/mod.d.cts",
38
+ "default": "./dist/mod.d.ts"
39
+ },
40
+ "import": "./dist/mod.js",
41
+ "require": "./dist/mod.cjs",
42
+ "default": "./dist/mod.js"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "dist/",
48
+ "package.json",
49
+ "README.md"
50
+ ],
51
+ "dependencies": {
52
+ "@fedify/vocab": "2.3.0"
53
+ },
54
+ "devDependencies": {
55
+ "tsdown": "^0.22.0",
56
+ "typescript": "^6.0.0"
57
+ },
58
+ "scripts": {
59
+ "build:self": "tsdown",
60
+ "build": "pnpm --filter @fedify/backfill... run build:self",
61
+ "prepublish": "pnpm build",
62
+ "pretest": "pnpm build",
63
+ "test": "cd dist/ && node --test",
64
+ "pretest:bun": "pnpm build",
65
+ "test:bun": "cd dist/ && bun test --timeout 60000"
66
+ }
67
+ }