@fedify/cli 2.3.0-dev.994 → 2.3.1

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.
Files changed (83) hide show
  1. package/dist/bench/action.js +469 -0
  2. package/dist/bench/actor/documents.js +39 -0
  3. package/dist/bench/actor/fleet.js +39 -0
  4. package/dist/bench/actor/keys.js +35 -0
  5. package/dist/bench/command.js +72 -0
  6. package/dist/bench/compare/schema.js +16 -0
  7. package/dist/bench/compare.js +667 -0
  8. package/dist/bench/discovery/discover.js +67 -0
  9. package/dist/bench/discovery/probe.js +50 -0
  10. package/dist/bench/load/arrival.js +27 -0
  11. package/dist/bench/load/clock.js +33 -0
  12. package/dist/bench/load/generator.js +145 -0
  13. package/dist/bench/metrics/aggregate.js +64 -0
  14. package/dist/bench/metrics/histogram.js +141 -0
  15. package/dist/bench/metrics/stats-client.js +216 -0
  16. package/dist/bench/mod.js +11 -0
  17. package/dist/bench/render/format.js +46 -0
  18. package/dist/bench/render/index.js +20 -0
  19. package/dist/bench/render/json.js +12 -0
  20. package/dist/bench/render/markdown.js +63 -0
  21. package/dist/bench/render/text.js +75 -0
  22. package/dist/bench/result/build.js +252 -0
  23. package/dist/bench/result/expect/assert.js +74 -0
  24. package/dist/bench/result/expect/evaluate.js +128 -0
  25. package/dist/bench/result/expect/metrics.js +34 -0
  26. package/dist/bench/result/schema.js +365 -0
  27. package/dist/bench/safety/gate.js +62 -0
  28. package/dist/bench/safety/tiers.js +97 -0
  29. package/dist/bench/scenario/coerce.js +24 -0
  30. package/dist/bench/scenario/errors.js +36 -0
  31. package/dist/bench/scenario/load.js +69 -0
  32. package/dist/bench/scenario/normalize.js +125 -0
  33. package/dist/bench/scenario/schema.js +399 -0
  34. package/dist/bench/scenario/units.js +56 -0
  35. package/dist/bench/scenario/validate.js +29 -0
  36. package/dist/bench/scenarios/actor.js +38 -0
  37. package/dist/bench/scenarios/failure.js +363 -0
  38. package/dist/bench/scenarios/fanout.js +261 -0
  39. package/dist/bench/scenarios/inbox.js +147 -0
  40. package/dist/bench/scenarios/mixed.js +244 -0
  41. package/dist/bench/scenarios/object-discovery.js +211 -0
  42. package/dist/bench/scenarios/object.js +54 -0
  43. package/dist/bench/scenarios/read.js +108 -0
  44. package/dist/bench/scenarios/registry.js +39 -0
  45. package/dist/bench/scenarios/runner.js +96 -0
  46. package/dist/bench/scenarios/webfinger.js +44 -0
  47. package/dist/bench/server/synthetic.js +118 -0
  48. package/dist/bench/signing/activity-id.js +18 -0
  49. package/dist/bench/signing/pipeline.js +134 -0
  50. package/dist/bench/signing/signer.js +39 -0
  51. package/dist/bench/template/generate.js +90 -0
  52. package/dist/bench/template/helpers.js +19 -0
  53. package/dist/bench/template/template.js +132 -0
  54. package/dist/cache.js +2 -2
  55. package/dist/commands.js +110 -0
  56. package/dist/config.js +15 -3
  57. package/dist/deno.js +1 -1
  58. package/dist/docloader.js +1 -1
  59. package/dist/generate-vocab/action.js +3 -3
  60. package/dist/generate-vocab/command.js +6 -4
  61. package/dist/imagerenderer.js +3 -3
  62. package/dist/inbox/command.js +6 -4
  63. package/dist/inbox/view.js +1 -1
  64. package/dist/inbox.js +4 -4
  65. package/dist/log.js +2 -2
  66. package/dist/lookup/command.js +121 -0
  67. package/dist/lookup.js +27 -138
  68. package/dist/mod.js +2 -20
  69. package/dist/nodeinfo.js +53 -12
  70. package/dist/options.js +1 -1
  71. package/dist/relay/command.js +6 -4
  72. package/dist/relay.js +3 -3
  73. package/dist/runner.js +70 -45
  74. package/dist/tunnel.js +8 -6
  75. package/dist/utils.js +9 -4
  76. package/dist/webfinger/action.js +1 -1
  77. package/dist/webfinger/command.js +6 -4
  78. package/dist/webfinger/error.js +2 -0
  79. package/dist/webfinger/lib.js +1 -1
  80. package/package.json +28 -23
  81. package/dist/generate-vocab/mod.js +0 -4
  82. package/dist/init/mod.js +0 -3
  83. package/dist/webfinger/mod.js +0 -4
@@ -0,0 +1,147 @@
1
+ import "@js-temporal/polyfill";
2
+ import { discoverInbox, selectInbox } from "../discovery/discover.js";
3
+ import { asList } from "../scenario/coerce.js";
4
+ import { runLoad } from "../load/generator.js";
5
+ import { aggregateSamples } from "../metrics/aggregate.js";
6
+ import { diffSnapshots, fetchServerSnapshot, snapshotToMetrics } from "../metrics/stats-client.js";
7
+ import { createSigningPipeline } from "../signing/pipeline.js";
8
+ import { estimateTotal, loadPlanOf, measuredWindowMs, sendRequest, validateInboxSelector, withMeasuredWindowStart } from "./runner.js";
9
+ import { createActivityIdMinter } from "../signing/activity-id.js";
10
+ import { signInboxDelivery } from "../signing/signer.js";
11
+ import { isGenerateDirective, resolveGenerate } from "../template/generate.js";
12
+ import { Create, Note } from "@fedify/vocab";
13
+ //#region src/bench/scenarios/inbox.ts
14
+ /**
15
+ * The `inbox` scenario runner: the end-to-end signed-delivery benchmark.
16
+ *
17
+ * It discovers the recipient's inbox the way a real peer does, then drives
18
+ * signed activity deliveries through the signing pipeline, aggregates the
19
+ * client-measured results, and reads the target's server-side metrics.
20
+ * @since 2.3.0
21
+ * @module
22
+ */
23
+ /** The `inbox` scenario runner. */
24
+ const inboxRunner = {
25
+ validate(scenario) {
26
+ validateActivity(scenario);
27
+ validateInbox(scenario);
28
+ },
29
+ async run(context) {
30
+ const { scenario, fleet } = context;
31
+ if (fleet == null || fleet.actors.length < 1) throw new Error("The inbox scenario requires the synthetic actor server.");
32
+ if (scenario.recipients.length < 1) throw new Error("The inbox scenario requires a recipient.");
33
+ validateActivity(scenario);
34
+ validateInbox(scenario);
35
+ const fetchImpl = context.fetch ?? fetch;
36
+ const targets = [];
37
+ for (const recipient of scenario.recipients) {
38
+ const discovered = await discoverInbox(recipient, {
39
+ documentLoader: context.documentLoader,
40
+ contextLoader: context.contextLoader,
41
+ allowPrivateAddress: context.allowPrivateAddress
42
+ });
43
+ const inbox = selectInbox(discovered, scenario.inbox);
44
+ await context.assertDestinationAllowed?.(inbox);
45
+ targets.push({
46
+ inbox,
47
+ actorUri: discovered.actorUri
48
+ });
49
+ }
50
+ const actors = fleet.actors;
51
+ const minter = createActivityIdMinter(fleet.url);
52
+ let index = 0;
53
+ const factory = () => {
54
+ const i = index++;
55
+ const actor = actors[i % actors.length];
56
+ const target = targets[i % targets.length];
57
+ const activity = buildActivity(scenario.activity, actor, minter.next(), fleet.url, target.actorUri);
58
+ return signInboxDelivery({
59
+ actor,
60
+ inbox: target.inbox,
61
+ activity,
62
+ contextLoader: context.contextLoader
63
+ });
64
+ };
65
+ const pipeline = createSigningPipeline(scenario.signing, factory, { total: estimateTotal(scenario) });
66
+ const rawSend = async () => {
67
+ let request;
68
+ try {
69
+ request = await pipeline.next();
70
+ } catch (error) {
71
+ return {
72
+ ok: false,
73
+ errorKind: "client",
74
+ reason: String(error)
75
+ };
76
+ }
77
+ return sendRequest(request, fetchImpl);
78
+ };
79
+ let baseline = null;
80
+ let baselineTaken = false;
81
+ const send = withMeasuredWindowStart(scenario.warmupMs, async () => {
82
+ baseline = await fetchServerSnapshot(context.target, fetchImpl);
83
+ baselineTaken = true;
84
+ }, rawSend);
85
+ try {
86
+ await pipeline.prime();
87
+ const measurement = aggregateSamples((await runLoad(loadPlanOf(scenario, context.rng), send, context.clock, context.signal)).samples, {
88
+ measuredWindowMs: measuredWindowMs(scenario),
89
+ includeHistogram: true
90
+ });
91
+ const end = await fetchServerSnapshot(context.target, fetchImpl);
92
+ const server = baselineTaken && baseline != null && end != null ? snapshotToMetrics(diffSnapshots(baseline, end)) : null;
93
+ return {
94
+ ...measurement,
95
+ server
96
+ };
97
+ } finally {
98
+ await pipeline.close();
99
+ }
100
+ }
101
+ };
102
+ /**
103
+ * Validates the scenario's `inbox` mode. `"shared"` and `"personal"` select a
104
+ * discovered inbox; any other value is an explicit inbox URL the run will POST
105
+ * to, so it must be a usable bare http(s) URL. Without this preflight check, a
106
+ * typo like `inbox: shraed` would crash `selectInbox` with an uncaught error
107
+ * mid-run, and a non-http URL would slip through to the send path.
108
+ */
109
+ function validateInbox(scenario) {
110
+ validateInboxSelector(scenario.name, scenario.inbox);
111
+ }
112
+ /**
113
+ * Rejects the activity options the inbox runner cannot yet honor: it always
114
+ * delivers a `Create` carrying an embedded `Note`, so a different activity or
115
+ * object type, or `embedObject: false`, is refused with a clear message.
116
+ */
117
+ function validateActivity(scenario) {
118
+ const spec = scenario.activity;
119
+ if (spec == null) return;
120
+ const badType = asList(spec.type).find((type) => type !== "Create");
121
+ if (badType != null) throw new Error(`Scenario "${scenario.name}": the inbox runner currently supports only Create activities; got ${JSON.stringify(badType)}.`);
122
+ if (spec.embedObject === false) throw new Error(`Scenario "${scenario.name}": the inbox runner always embeds the activity's object; embedObject: false is not yet supported.`);
123
+ const badObjectType = asList(spec.object?.type).find((type) => type !== "Note");
124
+ if (badObjectType != null) throw new Error(`Scenario "${scenario.name}": the inbox runner currently supports only Note objects; got ${JSON.stringify(badObjectType)}.`);
125
+ }
126
+ function buildActivity(spec, actor, id, base, recipient) {
127
+ const note = new Note({
128
+ id: new URL(`/objects/${crypto.randomUUID()}`, base),
129
+ attribution: actor.id,
130
+ content: resolveContent(spec?.object?.content),
131
+ to: recipient
132
+ });
133
+ return new Create({
134
+ id,
135
+ actor: actor.id,
136
+ object: note,
137
+ to: recipient
138
+ });
139
+ }
140
+ function resolveContent(content) {
141
+ if (content == null) return "Benchmark activity.";
142
+ if (typeof content === "string") return content;
143
+ if (isGenerateDirective(content)) return resolveGenerate(content);
144
+ return String(content);
145
+ }
146
+ //#endregion
147
+ export { inboxRunner };
@@ -0,0 +1,244 @@
1
+ import "@js-temporal/polyfill";
2
+ import { LogLinearHistogram } from "../metrics/histogram.js";
3
+ import { actorRunner } from "./actor.js";
4
+ import { fanoutRunner } from "./fanout.js";
5
+ import { failureRunner } from "./failure.js";
6
+ import { inboxRunner } from "./inbox.js";
7
+ import { objectRunner } from "./object.js";
8
+ import { webfingerRunner } from "./webfinger.js";
9
+ //#region src/bench/scenarios/mixed.ts
10
+ /**
11
+ * The `mixed` scenario runner.
12
+ * @since 2.3.0
13
+ * @module
14
+ */
15
+ /** The `mixed` scenario runner. */
16
+ const mixedRunner = {
17
+ validate(scenario, context) {
18
+ if (scenario.raw.mix == null || scenario.raw.mix.length < 1) throw new Error(`Scenario "${scenario.name}": mixed requires at least one mix entry.`);
19
+ for (const entry of scenario.raw.mix) if (entry.weight <= 0) throw new Error(`Scenario "${scenario.name}": mix entry ${entry.scenario} has a non-positive weight.`);
20
+ if (scenario.load.kind === "closed" && scenario.load.concurrency < scenario.raw.mix.length) throw new Error(`Scenario "${scenario.name}": closed-loop mixed load needs at least one concurrency slot per mix entry.`);
21
+ const serverExpectation = Object.keys(scenario.expect).find(isServerExpectation);
22
+ if (serverExpectation != null) throw new Error(`Scenario "${scenario.name}": mixed server-side expectations are not supported (${JSON.stringify(serverExpectation)}).`);
23
+ if (context?.scenarios != null) {
24
+ const children = childScenarios(scenario, context.scenarios);
25
+ for (const child of children) runnerForChild(child.type).validate?.(child, context);
26
+ validateTargetQueueObservation(scenario, children);
27
+ }
28
+ },
29
+ async run(context) {
30
+ this.validate?.(context.scenario, { scenarios: context.scenarios });
31
+ if (context.scenarios == null) throw new Error("The mixed scenario requires the resolved scenario list.");
32
+ const children = childScenarios(context.scenario, context.scenarios);
33
+ const fetchImpl = limitedFetch(context.fetch ?? fetch, context.scenario.load.maxInFlight);
34
+ const results = await Promise.allSettled(children.map((child) => runnerForChild(child.type).run({
35
+ ...context,
36
+ scenario: child,
37
+ fetch: fetchImpl,
38
+ assertDestinationAllowed: (url, scenario) => context.assertDestinationAllowed?.(url, scenario ?? child),
39
+ assertReadDestinationAllowed: (url, scenario) => context.assertReadDestinationAllowed?.(url, scenario ?? child),
40
+ assertActorlessDestinationAllowed: (url, scenario) => context.assertActorlessDestinationAllowed?.(url, scenario ?? child)
41
+ })));
42
+ const rejected = results.find((result) => result.status === "rejected");
43
+ if (rejected != null) throw rejected.reason;
44
+ return mergeMeasurements(results.filter((result) => result.status === "fulfilled").map((result) => result.value));
45
+ }
46
+ };
47
+ function isServerExpectation(metric) {
48
+ return metric.startsWith("signatureVerification.") || metric.startsWith("queueDrain.");
49
+ }
50
+ function validateTargetQueueObservation(scenario, children) {
51
+ const observers = children.filter(observesTargetQueue);
52
+ if (observers.length < 1) return;
53
+ if (children.filter(producesTargetQueue).length <= 1) return;
54
+ throw new Error(`Scenario "${scenario.name}": mixed scenarios cannot run queue-observing children (${observers.map((child) => child.name).join(", ")}) concurrently with other target queue producers because target queue counters are not scoped per child.`);
55
+ }
56
+ function observesTargetQueue(scenario) {
57
+ return scenario.type === "fanout" || scenario.type === "failure" && faultsOf(scenario).some(isRemoteFault);
58
+ }
59
+ function producesTargetQueue(scenario) {
60
+ return scenario.type === "inbox" || scenario.type === "fanout" || scenario.type === "failure" && faultsOf(scenario).some(isRemoteFault);
61
+ }
62
+ function faultsOf(scenario) {
63
+ return scenario.faults.length < 1 ? ["remote-404"] : scenario.faults;
64
+ }
65
+ function isRemoteFault(fault) {
66
+ return fault === "remote-404" || fault === "remote-410" || fault === "slow-inbox" || fault === "network-error";
67
+ }
68
+ function childScenarios(scenario, scenarios) {
69
+ const entries = scenario.raw.mix ?? [];
70
+ const totalWeight = entries.reduce((sum, entry) => sum + entry.weight, 0);
71
+ const closedLoads = scenario.load.kind === "closed" ? scaledClosedConcurrencies(scenario.load.concurrency, entries.map((entry) => entry.weight)) : void 0;
72
+ return entries.map((entry, index) => {
73
+ const children = scenarios.filter((candidate) => candidate.name === entry.scenario);
74
+ const child = children[0];
75
+ if (child == null) throw new Error(`Scenario "${scenario.name}": unknown mixed child ${JSON.stringify(entry.scenario)}.`);
76
+ if (children.length > 1) throw new Error(`Scenario "${scenario.name}": ambiguous mixed child ${JSON.stringify(entry.scenario)} matches ${children.length} scenarios.`);
77
+ if (child.type === "mixed") throw new Error(`Scenario "${scenario.name}": nested mixed scenarios are not supported.`);
78
+ const load = scaledLoad(scenario.load, entry.weight, totalWeight, closedLoads?.[index]);
79
+ return {
80
+ ...child,
81
+ name: `${scenario.name}/${child.name}`,
82
+ load,
83
+ durationMs: scenario.durationMs,
84
+ warmupMs: scenario.warmupMs,
85
+ signing: scenario.signing,
86
+ signatureTimeWindow: scenario.signatureTimeWindow,
87
+ expect: {},
88
+ raw: {
89
+ ...child.raw,
90
+ name: `${scenario.name}/${child.name}`,
91
+ load: rawLoad(load),
92
+ duration: `${scenario.durationMs}ms`,
93
+ warmup: `${scenario.warmupMs}ms`,
94
+ signing: scenario.signing,
95
+ signatureTimeWindow: scenario.signatureTimeWindow,
96
+ expect: {}
97
+ }
98
+ };
99
+ });
100
+ }
101
+ function scaledLoad(load, weight, totalWeight, closedConcurrency) {
102
+ if (load.kind === "open") return {
103
+ kind: "open",
104
+ ratePerSec: load.ratePerSec * (weight / totalWeight),
105
+ arrival: load.arrival
106
+ };
107
+ return {
108
+ kind: "closed",
109
+ concurrency: closedConcurrency ?? Math.max(1, Math.round(load.concurrency * weight / totalWeight))
110
+ };
111
+ }
112
+ function limitedFetch(fetchImpl, maxInFlight) {
113
+ if (maxInFlight == null) return fetchImpl;
114
+ const limiter = createLimiter(maxInFlight);
115
+ const limited = async (input, init) => {
116
+ const release = await limiter.acquire();
117
+ try {
118
+ return await fetchImpl(input, init);
119
+ } finally {
120
+ release();
121
+ }
122
+ };
123
+ return limited;
124
+ }
125
+ function createLimiter(maxInFlight) {
126
+ if (!Number.isInteger(maxInFlight) || maxInFlight < 1) throw new RangeError(`maxInFlight must be a positive integer; got ${maxInFlight}.`);
127
+ let active = 0;
128
+ const waiters = [];
129
+ return { async acquire() {
130
+ if (active < maxInFlight) active++;
131
+ else await new Promise((resolve) => waiters.push(resolve));
132
+ let released = false;
133
+ return () => {
134
+ if (released) return;
135
+ released = true;
136
+ const next = waiters.shift();
137
+ if (next == null) active--;
138
+ else next();
139
+ };
140
+ } };
141
+ }
142
+ function scaledClosedConcurrencies(concurrency, weights) {
143
+ const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
144
+ const ideal = weights.map((weight) => concurrency * weight / totalWeight);
145
+ const allocations = weights.map(() => 1);
146
+ for (let remaining = concurrency - weights.length; remaining > 0; remaining--) {
147
+ let best = 0;
148
+ for (let i = 1; i < allocations.length; i++) if (ideal[i] - allocations[i] > ideal[best] - allocations[best]) best = i;
149
+ allocations[best]++;
150
+ }
151
+ return allocations;
152
+ }
153
+ function rawLoad(load) {
154
+ if (load.kind === "open") return {
155
+ rate: load.ratePerSec,
156
+ arrival: load.arrival,
157
+ maxInFlight: load.maxInFlight
158
+ };
159
+ return {
160
+ concurrency: load.concurrency,
161
+ maxInFlight: load.maxInFlight
162
+ };
163
+ }
164
+ function runnerForChild(type) {
165
+ switch (type) {
166
+ case "inbox": return inboxRunner;
167
+ case "webfinger": return webfingerRunner;
168
+ case "actor": return actorRunner;
169
+ case "object": return objectRunner;
170
+ case "fanout": return fanoutRunner;
171
+ case "failure": return failureRunner;
172
+ default: throw new Error(`The "${type}" scenario type cannot be used inside a mixed scenario.`);
173
+ }
174
+ }
175
+ function mergeMeasurements(measurements) {
176
+ const total = measurements.reduce((sum, m) => sum + m.requests.total, 0);
177
+ const ok = measurements.reduce((sum, m) => sum + m.requests.ok, 0);
178
+ const histogram = mergeHistograms(measurements);
179
+ const deliveryThroughputs = measurements.map((m) => m.deliveryThroughputPerSec).filter((value) => value != null);
180
+ return {
181
+ requests: {
182
+ total,
183
+ ok,
184
+ failed: total - ok,
185
+ successRate: total === 0 ? 1 : ok / total
186
+ },
187
+ throughputPerSec: measurements.reduce((sum, m) => sum + m.throughputPerSec, 0),
188
+ ...deliveryThroughputs.length < 1 ? {} : { deliveryThroughputPerSec: deliveryThroughputs.reduce((sum, value) => sum + value, 0) },
189
+ client: { latencyMs: histogram == null ? mergeLatencyFallback(measurements) : latencyFromHistogram(histogram) },
190
+ server: null,
191
+ errors: mergeErrors(measurements),
192
+ ...histogram == null ? {} : { histogram: histogram.toJSON() }
193
+ };
194
+ }
195
+ function mergeHistograms(measurements) {
196
+ let merged = null;
197
+ for (const measurement of measurements) {
198
+ if (measurement.histogram == null) return null;
199
+ const histogram = LogLinearHistogram.fromJSON(measurement.histogram);
200
+ if (merged == null) merged = histogram;
201
+ else merged.merge(histogram);
202
+ }
203
+ return merged;
204
+ }
205
+ function latencyFromHistogram(histogram) {
206
+ return {
207
+ p50: histogram.percentile(50),
208
+ p95: histogram.percentile(95),
209
+ p99: histogram.percentile(99),
210
+ mean: histogram.mean,
211
+ max: histogram.max
212
+ };
213
+ }
214
+ function mergeLatencyFallback(measurements) {
215
+ const total = measurements.reduce((sum, m) => sum + m.requests.total, 0);
216
+ if (measurements.length < 1) return {
217
+ p50: 0,
218
+ p95: 0,
219
+ p99: 0,
220
+ mean: 0,
221
+ max: 0
222
+ };
223
+ return {
224
+ p50: Math.max(...measurements.map((m) => m.client.latencyMs.p50)),
225
+ p95: Math.max(...measurements.map((m) => m.client.latencyMs.p95)),
226
+ p99: Math.max(...measurements.map((m) => m.client.latencyMs.p99)),
227
+ mean: total === 0 ? 0 : measurements.reduce((sum, m) => sum + m.client.latencyMs.mean * m.requests.total, 0) / total,
228
+ max: Math.max(...measurements.map((m) => m.client.latencyMs.max))
229
+ };
230
+ }
231
+ function mergeErrors(measurements) {
232
+ const buckets = /* @__PURE__ */ new Map();
233
+ for (const measurement of measurements) for (const error of measurement.errors) {
234
+ const key = `${error.kind}|${error.status ?? ""}|${error.reason}`;
235
+ const existing = buckets.get(key);
236
+ buckets.set(key, {
237
+ ...error,
238
+ count: (existing?.count ?? 0) + error.count
239
+ });
240
+ }
241
+ return [...buckets.values()].sort((a, b) => b.count - a.count);
242
+ }
243
+ //#endregion
244
+ export { mixedRunner };
@@ -0,0 +1,211 @@
1
+ import "@js-temporal/polyfill";
2
+ import { convertUrlIfHandle } from "../../webfinger/lib.js";
3
+ import { asList } from "../scenario/coerce.js";
4
+ //#region src/bench/scenarios/object-discovery.ts
5
+ /**
6
+ * Discovery helpers for actor and object benchmark scenarios.
7
+ * @since 2.3.0
8
+ * @module
9
+ */
10
+ const ACTIVITY_JSON_ACCEPT = "application/activity+json, application/ld+json";
11
+ const WEBFINGER_ACCEPT = "application/jrd+json, application/json";
12
+ const MAX_COLLECTION_CRAWL_PAGES = 100;
13
+ const MAX_ACTIVITY_UNWRAP_DEPTH = 10;
14
+ const ACTIVITY_WRAPPER_TYPES = new Set([
15
+ "Accept",
16
+ "Add",
17
+ "Announce",
18
+ "Create",
19
+ "Delete",
20
+ "Dislike",
21
+ "Flag",
22
+ "Ignore",
23
+ "Invite",
24
+ "Join",
25
+ "Leave",
26
+ "Like",
27
+ "Listen",
28
+ "Move",
29
+ "Offer",
30
+ "Question",
31
+ "Read",
32
+ "Reject",
33
+ "Remove",
34
+ "TentativeAccept",
35
+ "TentativeReject",
36
+ "Travel",
37
+ "Undo",
38
+ "Update",
39
+ "View"
40
+ ]);
41
+ /** Resolves scenario recipients into actor document URLs. */
42
+ async function actorUrlsFromRecipients(recipients, options) {
43
+ const urls = [];
44
+ for (const recipient of recipients) urls.push(await actorUrlFromRecipient(recipient, options));
45
+ return urls;
46
+ }
47
+ /** Resolves object scenario sources into object URLs. */
48
+ async function objectUrlsFromSource(options) {
49
+ const { source } = options;
50
+ if (source == null) return [];
51
+ if (typeof source === "string" || Array.isArray(source)) return asList(source).map((url) => new URL(url));
52
+ const limit = source.limit ?? 100;
53
+ const types = new Set(asList(source.type));
54
+ const urls = [];
55
+ for (const seed of asList(source.seed)) {
56
+ const actorUrl = await actorUrlFromRecipient(seed, options);
57
+ await options.assertReadDestinationAllowed?.(actorUrl);
58
+ const actor = await fetchJson(actorUrl, options.fetch);
59
+ for (const collectionName of asList(source.collection ?? "outbox")) {
60
+ const collectionUrl = propertyUrl(actor, collectionName, actorUrl);
61
+ if (collectionUrl == null) continue;
62
+ for await (const objectUrl of crawlCollection(collectionUrl, {
63
+ fetch: options.fetch,
64
+ assertReadDestinationAllowed: options.assertReadDestinationAllowed,
65
+ types,
66
+ limit: limit - urls.length
67
+ })) {
68
+ urls.push(objectUrl);
69
+ if (urls.length >= limit) return urls;
70
+ }
71
+ }
72
+ }
73
+ return urls;
74
+ }
75
+ async function actorUrlFromRecipient(recipient, options) {
76
+ const identifier = convertUrlIfHandle(recipient);
77
+ if (identifier.protocol !== "acct:") return identifier;
78
+ const url = new URL("/.well-known/webfinger", options.target);
79
+ url.searchParams.set("resource", identifier.href);
80
+ const jrd = await fetchJson(url, options.fetch, WEBFINGER_ACCEPT);
81
+ const self = (Array.isArray(jrd.links) ? jrd.links : []).find((link) => isRecord(link) && link.rel === "self" && typeof link.href === "string");
82
+ if (!isRecord(self) || typeof self.href !== "string") throw new Error(`WebFinger response for ${recipient} has no self link.`);
83
+ return new URL(self.href);
84
+ }
85
+ async function* crawlCollection(start, options) {
86
+ let next = start;
87
+ let remaining = options.limit;
88
+ let pages = 0;
89
+ const visited = /* @__PURE__ */ new Set();
90
+ while (next != null && remaining > 0 && pages < MAX_COLLECTION_CRAWL_PAGES) {
91
+ if (visited.has(next.href)) return;
92
+ visited.add(next.href);
93
+ await options.assertReadDestinationAllowed?.(next);
94
+ const page = await fetchJson(next, options.fetch);
95
+ pages++;
96
+ const items = arrayProperty(page, "orderedItems") ?? arrayProperty(page, "items") ?? [];
97
+ for (const item of items) {
98
+ const url = await objectUrl(item, {
99
+ base: next,
100
+ fetch: options.fetch,
101
+ assertReadDestinationAllowed: options.assertReadDestinationAllowed,
102
+ types: options.types
103
+ });
104
+ if (url == null) continue;
105
+ yield url;
106
+ remaining--;
107
+ if (remaining <= 0) return;
108
+ }
109
+ const first = propertyUrl(page, "first", next);
110
+ next = propertyUrl(page, "next", next) ?? (next.href === start.href ? first : null);
111
+ }
112
+ }
113
+ async function fetchJson(url, fetchImpl = fetch, accept = ACTIVITY_JSON_ACCEPT) {
114
+ const response = await fetchImpl(new Request(url, {
115
+ headers: { accept },
116
+ redirect: "manual"
117
+ }));
118
+ if (!response.ok) {
119
+ await response.arrayBuffer().catch(() => {});
120
+ throw new Error(`Failed to fetch ${url.href}: HTTP ${response.status}.`);
121
+ }
122
+ let json;
123
+ try {
124
+ json = await response.json();
125
+ } catch (error) {
126
+ throw new Error(`Failed to parse JSON from ${url.href}: ${error}`);
127
+ }
128
+ if (!isRecord(json)) throw new Error(`Expected ${url.href} to return a JSON object.`);
129
+ return json;
130
+ }
131
+ async function objectUrl(item, options) {
132
+ for (const candidate of objectCandidates(item)) {
133
+ if (typeof candidate === "string") {
134
+ const url = safeUrl(candidate, options.base);
135
+ if (url == null) continue;
136
+ if (options.types.size < 1) return url;
137
+ const typedUrl = await typedReferencedObjectUrl(url, options);
138
+ if (typedUrl != null) return typedUrl;
139
+ continue;
140
+ }
141
+ if (!isRecord(candidate)) continue;
142
+ if (options.types.size > 0 && !matchesType(candidate.type, options.types)) continue;
143
+ const url = propertyUrl(candidate, "id", options.base);
144
+ if (url != null) return url;
145
+ }
146
+ return null;
147
+ }
148
+ async function typedReferencedObjectUrl(url, options, seen = /* @__PURE__ */ new Set(), depth = 0) {
149
+ if (depth > MAX_ACTIVITY_UNWRAP_DEPTH) return null;
150
+ if (seen.has(url.href)) return null;
151
+ seen.add(url.href);
152
+ await options.assertReadDestinationAllowed?.(url);
153
+ let object;
154
+ try {
155
+ object = await fetchJson(url, options.fetch);
156
+ } catch {
157
+ return null;
158
+ }
159
+ for (const candidate of objectCandidates(object)) {
160
+ if (typeof candidate === "string") {
161
+ const candidateUrl = safeUrl(candidate, url);
162
+ if (candidateUrl == null) continue;
163
+ const typedUrl = await typedReferencedObjectUrl(candidateUrl, options, seen, depth + 1);
164
+ if (typedUrl != null) return typedUrl;
165
+ continue;
166
+ }
167
+ if (!isRecord(candidate)) continue;
168
+ if (!matchesType(candidate.type, options.types)) continue;
169
+ return propertyUrl(candidate, "id", url) ?? url;
170
+ }
171
+ return null;
172
+ }
173
+ function objectCandidates(item, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
174
+ if (depth > MAX_ACTIVITY_UNWRAP_DEPTH) return [];
175
+ if (!isRecord(item) || !matchesType(item.type, ACTIVITY_WRAPPER_TYPES)) return [item];
176
+ if (seen.has(item)) return [];
177
+ seen.add(item);
178
+ const object = item.object;
179
+ if (object == null) return [];
180
+ if (Array.isArray(object)) return object.flatMap((entry) => entry == null ? [] : objectCandidates(entry, depth + 1, seen));
181
+ return objectCandidates(object, depth + 1, seen);
182
+ }
183
+ function matchesType(type, expected) {
184
+ if (typeof type === "string") return expected.has(type);
185
+ return Array.isArray(type) && type.some((item) => typeof item === "string" && expected.has(item));
186
+ }
187
+ function propertyUrl(object, key, base) {
188
+ const value = object[key];
189
+ if (typeof value === "string") return safeUrl(value, base);
190
+ if (isRecord(value)) {
191
+ if (typeof value.href === "string") return safeUrl(value.href, base);
192
+ if (typeof value.id === "string") return safeUrl(value.id, base);
193
+ }
194
+ return null;
195
+ }
196
+ function arrayProperty(object, key) {
197
+ const value = object[key];
198
+ return Array.isArray(value) ? value : null;
199
+ }
200
+ function isRecord(value) {
201
+ return value != null && typeof value === "object" && !Array.isArray(value);
202
+ }
203
+ function safeUrl(value, base) {
204
+ try {
205
+ return new URL(value, base);
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+ //#endregion
211
+ export { actorUrlsFromRecipients, objectUrlsFromSource };
@@ -0,0 +1,54 @@
1
+ import "@js-temporal/polyfill";
2
+ import { convertUrlIfHandle } from "../../webfinger/lib.js";
3
+ import { asList } from "../scenario/coerce.js";
4
+ import { objectUrlsFromSource } from "./object-discovery.js";
5
+ import { assertBareHttpUrl, isBareHttpUrl } from "./runner.js";
6
+ import { runReadLoad } from "./read.js";
7
+ //#region src/bench/scenarios/object.ts
8
+ /**
9
+ * The `object` scenario runner.
10
+ * @since 2.3.0
11
+ * @module
12
+ */
13
+ /** The `object` scenario runner. */
14
+ const objectRunner = {
15
+ validate(scenario) {
16
+ const { source } = scenario;
17
+ if (source == null) return;
18
+ if (typeof source === "string" || Array.isArray(source)) {
19
+ for (const url of asList(source)) {
20
+ let parsed;
21
+ try {
22
+ parsed = new URL(url);
23
+ } catch {
24
+ throw new Error(`Scenario "${scenario.name}": invalid object source URL ${JSON.stringify(url)}.`);
25
+ }
26
+ assertBareHttpUrl(scenario.name, "object source URL", parsed);
27
+ }
28
+ return;
29
+ }
30
+ for (const seed of asList(source.seed)) {
31
+ let url;
32
+ try {
33
+ url = convertUrlIfHandle(seed);
34
+ } catch {
35
+ throw new Error(`Scenario "${scenario.name}": invalid object source seed URL ${JSON.stringify(seed)}.`);
36
+ }
37
+ if (url.protocol !== "acct:" && !isBareHttpUrl(url)) throw new Error(`Scenario "${scenario.name}": object source seed must be an acct: handle or a bare http(s) URL with a host and no credentials; got ${JSON.stringify(url.href)}.`);
38
+ }
39
+ },
40
+ async run(context) {
41
+ this.validate?.(context.scenario);
42
+ return await runReadLoad(context, {
43
+ urls: await objectUrlsFromSource({
44
+ source: context.scenario.source,
45
+ target: context.target,
46
+ fetch: context.fetch,
47
+ assertReadDestinationAllowed: context.assertReadDestinationAllowed
48
+ }),
49
+ authenticated: context.scenario.authenticated
50
+ });
51
+ }
52
+ };
53
+ //#endregion
54
+ export { objectRunner };