@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,469 @@
1
+ import "@js-temporal/polyfill";
2
+ import { describeError } from "../utils.js";
3
+ import { getContextLoader, getDocumentLoader } from "../docloader.js";
4
+ import { buildFleet } from "./actor/fleet.js";
5
+ import { convertUrlIfHandle } from "../webfinger/lib.js";
6
+ import { discoverInbox, selectInbox } from "./discovery/discover.js";
7
+ import { actorUrlsFromRecipients, objectUrlsFromSource } from "./scenarios/object-discovery.js";
8
+ import { validateExpectBlock } from "./result/expect/evaluate.js";
9
+ import { buildReport, buildScenarioResult, configHash, detectEnvironment } from "./result/build.js";
10
+ import { probeBenchmarkMode } from "./discovery/probe.js";
11
+ import { renderReport } from "./render/index.js";
12
+ import { loadSuiteFile, renderSuiteTemplates } from "./scenario/load.js";
13
+ import { normalizeSuite } from "./scenario/normalize.js";
14
+ import { validateSuite } from "./scenario/validate.js";
15
+ import { UnsafeTargetError, assertInboxDestinationAllowed, assertTargetAllowed, assertUnsafeOverrideAllowed } from "./safety/gate.js";
16
+ import { classifyResolvedTarget } from "./safety/tiers.js";
17
+ import { resolveAdvertiseHost, spawnSyntheticServer } from "./server/synthetic.js";
18
+ import { runnerFor } from "./scenarios/registry.js";
19
+ import process from "node:process";
20
+ import { writeFile } from "node:fs/promises";
21
+ //#region src/bench/action.ts
22
+ /**
23
+ * Runs the `fedify bench` command: load and validate the suite, gate the
24
+ * target, run each scenario, and render the report. The process exits 0 when
25
+ * every `expect` gate passes and 1 otherwise; configuration and safety errors
26
+ * exit 2.
27
+ * @param command The parsed `bench` command options.
28
+ * @param deps Injectable dependencies for testing.
29
+ */
30
+ async function runBench(command, deps = {}) {
31
+ const exit = deps.exit ?? ((code) => {
32
+ process.exitCode = code;
33
+ });
34
+ const writeOutput = deps.writeOutput ?? defaultWriteOutput;
35
+ const log = deps.log ?? ((message) => process.stderr.write(`${message}\n`));
36
+ const signal = deps.signal;
37
+ const fetchImpl = withUserAgent(withAbortSignal(deps.fetch ?? fetch, signal), command.userAgent);
38
+ const explicitCliTarget = command.explicitCliTarget ?? command.target != null;
39
+ throwIfAborted(signal);
40
+ let validated;
41
+ let suite;
42
+ try {
43
+ validated = validateSuite(renderSuiteTemplates(await loadSuiteFile(command.scenario), command.target), command.scenario);
44
+ suite = normalizeSuite(validated, { target: command.target });
45
+ } catch (error) {
46
+ log(describeError(error));
47
+ exit(2);
48
+ return;
49
+ }
50
+ throwIfAborted(signal);
51
+ let runners;
52
+ try {
53
+ runners = suite.scenarios.map((scenario) => {
54
+ const runner = runnerFor(scenario.type);
55
+ runner.validate?.(scenario, { scenarios: suite.scenarios });
56
+ validateExpectBlock(scenario.expect);
57
+ return runner;
58
+ });
59
+ if (command.advertiseHost != null) resolveAdvertiseHost(command.advertiseHost);
60
+ } catch (error) {
61
+ log(describeError(error));
62
+ exit(2);
63
+ return;
64
+ }
65
+ throwIfAborted(signal);
66
+ const tier = await classifyResolvedTarget(suite.target, deps.resolveTargetAddresses);
67
+ throwIfAborted(signal);
68
+ const probe = await probeBenchmarkMode(suite.target, fetchImpl);
69
+ throwIfAborted(signal);
70
+ try {
71
+ if (!command.dryRun) assertUnsafeOverrideAllowed({
72
+ tier,
73
+ benchmarkMode: probe.benchmarkMode,
74
+ allowUnsafe: command.allowUnsafeTarget,
75
+ explicitCliTarget,
76
+ scenarios: unsafeOverrideScenarios(validated)
77
+ });
78
+ assertTargetAllowed({
79
+ tier,
80
+ benchmarkMode: probe.benchmarkMode,
81
+ allowUnsafe: command.allowUnsafeTarget,
82
+ dryRun: command.dryRun
83
+ });
84
+ } catch (error) {
85
+ if (error instanceof UnsafeTargetError) {
86
+ log(error.message);
87
+ exit(2);
88
+ return;
89
+ }
90
+ throw error;
91
+ }
92
+ const allowPrivateAddress = tier !== "public";
93
+ const documentLoader = await getDocumentLoader({
94
+ allowPrivateAddress,
95
+ userAgent: command.userAgent
96
+ });
97
+ const contextLoader = await getContextLoader({
98
+ allowPrivateAddress,
99
+ userAgent: command.userAgent
100
+ });
101
+ const assertDestinationAllowed = async (url, scenario) => {
102
+ const destinationTier = url.origin === suite.target.origin ? tier : await classifyResolvedTarget(url, deps.resolveTargetAddresses);
103
+ assertInboxDestinationAllowed(url, {
104
+ targetOrigin: suite.target.origin,
105
+ targetTier: tier,
106
+ destinationTier,
107
+ targetBenchmarkMode: probe.benchmarkMode,
108
+ allowUnsafe: command.allowUnsafeTarget,
109
+ advertised: command.advertiseHost != null
110
+ });
111
+ assertPublicDestinationOverrideAllowed(url, scenario, {
112
+ targetOrigin: suite.target.origin,
113
+ targetBenchmarkMode: probe.benchmarkMode,
114
+ allowUnsafe: command.allowUnsafeTarget,
115
+ explicitCliTarget,
116
+ destinationTier,
117
+ defaults: validated.defaults
118
+ });
119
+ };
120
+ const assertDestinationWithoutSyntheticServerAllowed = async (url, scenario, loadDescription) => {
121
+ const sameOrigin = url.origin === suite.target.origin;
122
+ const destinationTier = sameOrigin ? tier : await classifyResolvedTarget(url, deps.resolveTargetAddresses);
123
+ const inheritsTargetGate = sameOrigin && probe.benchmarkMode;
124
+ if (destinationTier === "public" && !inheritsTargetGate && !command.allowUnsafeTarget) throw new UnsafeTargetError(`Refusing to send ${loadDescription} to ${url.href}: it is public and not part of the benchmarked target. Pass --allow-unsafe-target to override.`);
125
+ assertPublicDestinationOverrideAllowed(url, scenario, {
126
+ targetOrigin: suite.target.origin,
127
+ targetBenchmarkMode: probe.benchmarkMode,
128
+ allowUnsafe: command.allowUnsafeTarget,
129
+ explicitCliTarget,
130
+ destinationTier,
131
+ defaults: validated.defaults
132
+ });
133
+ };
134
+ const assertReadDestinationAllowed = (url, scenario) => assertDestinationWithoutSyntheticServerAllowed(url, scenario, "benchmark read load");
135
+ const assertActorlessDestinationAllowed = (url, scenario) => assertDestinationWithoutSyntheticServerAllowed(url, scenario, "benchmark load");
136
+ if (command.dryRun) try {
137
+ await writeOutput(await renderPlan(suite, {
138
+ documentLoader,
139
+ contextLoader,
140
+ allowPrivateAddress,
141
+ fetch: fetchImpl,
142
+ assertDestinationAllowed,
143
+ assertReadDestinationAllowed
144
+ }), command.output);
145
+ exit(0);
146
+ return;
147
+ } catch (error) {
148
+ log(describeError(error));
149
+ exit(2);
150
+ return;
151
+ }
152
+ if (tier !== "loopback" && command.advertiseHost == null && suite.scenarios.some((scenario) => scenarioNeedsReachableLocalServer(scenario, suite.scenarios))) {
153
+ log("Some scenarios need benchmark-owned local servers to be reachable from the target. A loopback target reaches them automatically; for a non-loopback target, pass --advertise-host with an address the target can reach, or use a scenario that does not need local benchmark servers such as webfinger.");
154
+ exit(2);
155
+ return;
156
+ }
157
+ let fleet;
158
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
159
+ try {
160
+ throwIfAborted(signal);
161
+ if (suite.scenarios.some((scenario) => scenarioNeedsSyntheticServer(scenario, suite.scenarios))) fleet = await spawnSyntheticServer(await buildFleet(suite.actors), { advertiseHost: command.advertiseHost });
162
+ const results = [];
163
+ for (let i = 0; i < suite.scenarios.length; i++) {
164
+ const scenario = suite.scenarios[i];
165
+ const measurements = [];
166
+ for (let run = 1; run <= scenario.runs; run++) {
167
+ throwIfAborted(signal);
168
+ const suffix = scenario.runs === 1 ? "" : ` run ${run}/${scenario.runs}`;
169
+ log(`Running scenario "${scenario.name}" (${scenario.type})${suffix}…`);
170
+ measurements.push(await runners[i].run({
171
+ scenario,
172
+ scenarios: suite.scenarios,
173
+ target: suite.target,
174
+ documentLoader,
175
+ contextLoader,
176
+ allowPrivateAddress,
177
+ fleet: fleet ?? null,
178
+ advertiseHost: command.advertiseHost,
179
+ fetch: fetchImpl,
180
+ assertDestinationAllowed: (url, gateScenario) => assertDestinationAllowed(url, gateScenario ?? scenario),
181
+ assertReadDestinationAllowed: (url, gateScenario) => assertReadDestinationAllowed(url, gateScenario ?? scenario),
182
+ assertActorlessDestinationAllowed: (url, gateScenario) => assertActorlessDestinationAllowed(url, gateScenario ?? scenario),
183
+ signal
184
+ }));
185
+ throwIfAborted(signal);
186
+ }
187
+ results.push(buildScenarioResult(scenario, measurements));
188
+ }
189
+ const report = buildReport({
190
+ scenarios: results,
191
+ environment: detectEnvironment(),
192
+ target: {
193
+ url: suite.target.href,
194
+ fedifyVersion: probe.fedifyVersion,
195
+ statsAvailable: probe.benchmarkMode
196
+ },
197
+ startedAt,
198
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
199
+ suite: { configHash: configHash({
200
+ suite: validated,
201
+ target: suite.target.href
202
+ }) }
203
+ });
204
+ await writeOutput(renderReport(report, command.format), command.output);
205
+ exit(report.passed ? 0 : 1);
206
+ return;
207
+ } catch (error) {
208
+ if (error instanceof UnsafeTargetError) {
209
+ log(error.message);
210
+ exit(2);
211
+ return;
212
+ }
213
+ throw error;
214
+ } finally {
215
+ await fleet?.close();
216
+ }
217
+ }
218
+ /**
219
+ * Wraps a fetch implementation so every request carries the given User-Agent,
220
+ * unless the caller already set one. A prebuilt {@link Request} (the signed
221
+ * inbox delivery, a WebFinger GET) is mutated in place rather than recloned, so
222
+ * an already-signed body and its digest are left untouched; the User-Agent is
223
+ * not part of the signed header set, so adding it does not affect verification.
224
+ * @param fetchImpl The underlying fetch implementation.
225
+ * @param userAgent The User-Agent header value to apply.
226
+ * @returns A fetch implementation that injects the User-Agent.
227
+ */
228
+ function withUserAgent(fetchImpl, userAgent) {
229
+ return ((input, init) => {
230
+ if (input instanceof Request && init === void 0) {
231
+ if (input.headers.has("user-agent")) return fetchImpl(input);
232
+ try {
233
+ input.headers.set("user-agent", userAgent);
234
+ return fetchImpl(input);
235
+ } catch {
236
+ const headers = new Headers(input.headers);
237
+ headers.set("user-agent", userAgent);
238
+ return fetchImpl(new Request(input, { headers }));
239
+ }
240
+ }
241
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
242
+ if (!headers.has("user-agent")) headers.set("user-agent", userAgent);
243
+ return fetchImpl(input, {
244
+ ...init,
245
+ headers
246
+ });
247
+ });
248
+ }
249
+ function withAbortSignal(fetchImpl, signal) {
250
+ if (signal == null) return fetchImpl;
251
+ return ((input, init) => {
252
+ if (signal.aborted) return Promise.reject(abortReason(signal));
253
+ return fetchImpl(input, {
254
+ ...init,
255
+ signal
256
+ });
257
+ });
258
+ }
259
+ function throwIfAborted(signal) {
260
+ if (signal?.aborted) throw abortReason(signal);
261
+ }
262
+ function abortReason(signal) {
263
+ return signal.reason ?? /* @__PURE__ */ new Error("Benchmark run aborted.");
264
+ }
265
+ async function defaultWriteOutput(content, outputPath) {
266
+ if (outputPath == null) {
267
+ process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
268
+ return;
269
+ }
270
+ await writeFile(outputPath, content, { encoding: "utf-8" });
271
+ }
272
+ async function renderPlan(suite, context) {
273
+ const lines = [
274
+ "Fedify benchmark plan (dry run)",
275
+ "",
276
+ `Target: ${suite.target.href}`,
277
+ ""
278
+ ];
279
+ for (const scenario of suite.scenarios) {
280
+ lines.push(`- ${scenario.name} (${scenario.type}): ${describePlan(scenario)}`);
281
+ lines.push(...await describeDiscoveryPlan(scenario, suite, context));
282
+ }
283
+ lines.push("", "No benchmark load was sent. Discovery and stats probe requests may have been sent.");
284
+ return `${lines.join("\n")}\n`;
285
+ }
286
+ function describePlan(scenario) {
287
+ const load = scenario.load.kind === "open" ? `open-loop ${scenario.load.ratePerSec}/s ${scenario.load.arrival}` : `closed-loop concurrency ${scenario.load.concurrency}`;
288
+ const totalDurationMs = scenario.durationMs * scenario.runs;
289
+ const volume = describePlannedRequestVolume(scenario);
290
+ return [
291
+ load,
292
+ `duration ${scenario.durationMs}ms`,
293
+ `runs ${scenario.runs}`,
294
+ `total duration ${totalDurationMs}ms`,
295
+ ...volume == null ? [] : [volume],
296
+ `signing ${scenario.signing}`
297
+ ].join(", ");
298
+ }
299
+ function describePlannedRequestVolume(scenario) {
300
+ if (scenario.load.kind !== "open") return null;
301
+ return `estimated scheduled requests ${formatPlanNumber(scenario.load.ratePerSec * (scenario.durationMs / 1e3) * scenario.runs)}`;
302
+ }
303
+ function formatPlanNumber(value) {
304
+ if (Number.isInteger(value)) return String(value);
305
+ const formatted = value.toFixed(2).replace(/\.?0+$/, "");
306
+ return formatted === "" ? "0" : formatted;
307
+ }
308
+ async function describeDiscoveryPlan(scenario, suite, context) {
309
+ switch (scenario.type) {
310
+ case "inbox": return await describeInboxDiscoveryPlan(scenario, context);
311
+ case "webfinger": return describeWebFingerPlan(scenario, suite.target);
312
+ case "actor": return await describeActorPlan(scenario, suite, context);
313
+ case "object": return await describeObjectPlan(scenario, suite, context);
314
+ case "mixed": return describeMixedPlan(scenario);
315
+ default: return [" discovery: not available for this scenario type"];
316
+ }
317
+ }
318
+ async function describeInboxDiscoveryPlan(scenario, context) {
319
+ const lines = [];
320
+ for (const recipient of scenario.recipients) {
321
+ let discovered;
322
+ try {
323
+ discovered = await discoverInbox(recipient, {
324
+ documentLoader: context.documentLoader,
325
+ contextLoader: context.contextLoader,
326
+ allowPrivateAddress: context.allowPrivateAddress
327
+ });
328
+ } catch (error) {
329
+ lines.push(` recipient ${recipient}: discovery failed (${describeError(error)})`);
330
+ continue;
331
+ }
332
+ const inbox = selectInbox(discovered, scenario.inbox);
333
+ lines.push(` recipient ${recipient}: actor ${discovered.actorUri.href}, inbox ${inbox.href}`);
334
+ lines.push(` destination safety: ${await describeDestinationSafety(inbox, scenario, context)}`);
335
+ }
336
+ return lines;
337
+ }
338
+ function describeWebFingerPlan(scenario, target) {
339
+ return (scenario.recipients.length > 0 ? scenario.recipients : [target.href]).map((recipient) => {
340
+ const resource = convertUrlIfHandle(recipient).href;
341
+ const url = new URL("/.well-known/webfinger", target);
342
+ url.searchParams.set("resource", resource);
343
+ return ` webfinger ${resource}: GET ${url.href}`;
344
+ });
345
+ }
346
+ async function describeActorPlan(scenario, suite, context) {
347
+ try {
348
+ const urls = await actorUrlsFromRecipients(scenario.recipients, {
349
+ target: suite.target,
350
+ fetch: context.fetch
351
+ });
352
+ const lines = [];
353
+ for (const url of urls) {
354
+ lines.push(` actor: GET ${url.href}`);
355
+ lines.push(` destination safety: ${await describeDestinationSafety(url, scenario, context)}`);
356
+ }
357
+ return lines;
358
+ } catch (error) {
359
+ return [` actor discovery failed (${describeError(error)})`];
360
+ }
361
+ }
362
+ async function describeObjectPlan(scenario, suite, context) {
363
+ try {
364
+ const urls = await objectUrlsFromSource({
365
+ source: scenario.source,
366
+ target: suite.target,
367
+ fetch: context.fetch,
368
+ assertReadDestinationAllowed: (url) => context.assertReadDestinationAllowed(url, scenario)
369
+ });
370
+ const lines = [` objects: ${urls.length} URL(s) resolved`];
371
+ for (const url of urls.slice(0, 10)) {
372
+ lines.push(` object: GET ${url.href}`);
373
+ lines.push(` destination safety: ${await describeDestinationSafety(url, scenario, context)}`);
374
+ }
375
+ if (urls.length > 10) lines.push(` ... ${urls.length - 10} more`);
376
+ return lines;
377
+ } catch (error) {
378
+ return [` object discovery failed (${describeError(error)})`];
379
+ }
380
+ }
381
+ function describeMixedPlan(scenario) {
382
+ const entries = scenario.raw.mix ?? [];
383
+ if (entries.length < 1) return [" mix: no child scenarios"];
384
+ return entries.map((entry) => ` mix: ${entry.scenario} weight ${entry.weight}`);
385
+ }
386
+ async function describeDestinationSafety(url, scenario, context) {
387
+ try {
388
+ if (usesReadDestinationGate(scenario)) await context.assertReadDestinationAllowed(url, scenario);
389
+ else await context.assertDestinationAllowed(url, scenario);
390
+ return "allowed";
391
+ } catch (error) {
392
+ if (error instanceof UnsafeTargetError) return `would be refused: ${error.message}`;
393
+ throw error;
394
+ }
395
+ }
396
+ function usesReadDestinationGate(scenario) {
397
+ return (scenario.type === "actor" || scenario.type === "object") && !scenario.authenticated;
398
+ }
399
+ function assertPublicDestinationOverrideAllowed(url, scenario, context) {
400
+ const inheritsTargetGate = url.origin === context.targetOrigin && context.targetBenchmarkMode;
401
+ if (context.destinationTier !== "public" || inheritsTargetGate || !context.allowUnsafe) return;
402
+ assertUnsafeOverrideAllowed({
403
+ tier: "public",
404
+ benchmarkMode: false,
405
+ allowUnsafe: true,
406
+ explicitCliTarget: context.explicitCliTarget,
407
+ scenarios: [unsafeOverrideScenario(scenario, context.defaults)]
408
+ });
409
+ }
410
+ function unsafeOverrideScenarios(suite) {
411
+ return suite.scenarios.map((scenario) => unsafeOverrideScenario(scenario, suite.defaults));
412
+ }
413
+ function unsafeOverrideScenario(scenario, defaults) {
414
+ const defaultDuration = defaults?.duration != null;
415
+ const defaultLoad = hasExplicitLoad(defaults?.load);
416
+ const defaultRuns = defaults?.runs != null;
417
+ const raw = "raw" in scenario ? scenario.raw : scenario;
418
+ return {
419
+ name: scenario.name,
420
+ explicitDuration: raw.duration != null || defaultDuration,
421
+ explicitLoad: hasExplicitLoad(raw.load) || defaultLoad,
422
+ explicitRuns: raw.runs != null || defaultRuns
423
+ };
424
+ }
425
+ function hasExplicitLoad(load) {
426
+ return load != null && typeof load === "object" && ("rate" in load && load.rate != null || "concurrency" in load && load.concurrency != null);
427
+ }
428
+ function scenarioNeedsSyntheticServer(scenario, scenarios, seen = /* @__PURE__ */ new Set()) {
429
+ if (seen.has(scenario.name)) return false;
430
+ const nextSeen = new Set(seen).add(scenario.name);
431
+ switch (scenario.type) {
432
+ case "inbox": return true;
433
+ case "actor":
434
+ case "object": return scenario.authenticated;
435
+ case "failure": return failureFaultsOf(scenario).some(isInboundFailureFault);
436
+ case "mixed": return mixedChildrenOf(scenario, scenarios).some((child) => scenarioNeedsSyntheticServer(child, scenarios, nextSeen));
437
+ default: return false;
438
+ }
439
+ }
440
+ function scenarioNeedsReachableLocalServer(scenario, scenarios, seen = /* @__PURE__ */ new Set()) {
441
+ if (scenario.type === "fanout") return scenario.raw.sinkBase == null;
442
+ if (scenario.type === "failure") {
443
+ const faults = failureFaultsOf(scenario);
444
+ return faults.includes("invalid-signature") || scenario.raw.sinkBase == null && faults.some(isRemoteFailureFault);
445
+ }
446
+ if (scenario.type === "mixed") {
447
+ if (seen.has(scenario.name)) return false;
448
+ const nextSeen = new Set(seen).add(scenario.name);
449
+ return mixedChildrenOf(scenario, scenarios).some((child) => scenarioNeedsReachableLocalServer(child, scenarios, nextSeen));
450
+ }
451
+ return scenarioNeedsSyntheticServer(scenario, scenarios, seen);
452
+ }
453
+ function failureFaultsOf(scenario) {
454
+ return scenario.faults.length < 1 ? ["remote-404"] : scenario.faults;
455
+ }
456
+ function mixedChildrenOf(scenario, scenarios) {
457
+ return (scenario.raw.mix ?? []).flatMap((entry) => {
458
+ const child = scenarios.find((candidate) => candidate.name === entry.scenario);
459
+ return child == null ? [] : [child];
460
+ });
461
+ }
462
+ function isInboundFailureFault(fault) {
463
+ return fault === "invalid-signature" || fault === "missing-actor";
464
+ }
465
+ function isRemoteFailureFault(fault) {
466
+ return fault === "remote-404" || fault === "remote-410" || fault === "slow-inbox" || fault === "network-error";
467
+ }
468
+ //#endregion
469
+ export { runBench as default };
@@ -0,0 +1,39 @@
1
+ import "@js-temporal/polyfill";
2
+ import { Application, CryptographicKey, Multikey } from "@fedify/vocab";
3
+ //#region src/bench/actor/documents.ts
4
+ /**
5
+ * Building the ActivityPub actor documents the synthetic key server serves.
6
+ *
7
+ * The target dereferences a signature's `keyId` during verification; serving a
8
+ * normal actor document with an embedded `publicKey` (RSA, for HTTP and LD
9
+ * Signatures) and `assertionMethod` (Ed25519 Multikey, for FEP-8b32) is exactly
10
+ * what a real actor exposes, so verification resolves the key the same way.
11
+ * @since 2.3.0
12
+ * @module
13
+ */
14
+ /**
15
+ * Renders a synthetic actor as a compact JSON-LD actor document.
16
+ * @param actor The synthetic actor, with its URLs and keys.
17
+ * @param options The context loader used to compact the document.
18
+ * @returns The JSON-LD actor document.
19
+ */
20
+ async function actorDocument(actor, options) {
21
+ return await new Application({
22
+ id: actor.id,
23
+ preferredUsername: `bench-${actor.index}`,
24
+ name: actor.name ?? `Benchmark actor ${actor.index}`,
25
+ inbox: new URL(`${actor.id.href}/inbox`),
26
+ publicKey: actor.keys.rsa == null ? void 0 : new CryptographicKey({
27
+ id: actor.rsaKeyId,
28
+ owner: actor.id,
29
+ publicKey: actor.keys.rsa.publicKey
30
+ }),
31
+ assertionMethods: actor.keys.ed25519 == null ? [] : [new Multikey({
32
+ id: actor.ed25519KeyId,
33
+ controller: actor.id,
34
+ publicKey: actor.keys.ed25519.publicKey
35
+ })]
36
+ }).toJsonLd({ contextLoader: options.contextLoader });
37
+ }
38
+ //#endregion
39
+ export { actorDocument };
@@ -0,0 +1,39 @@
1
+ import "@js-temporal/polyfill";
2
+ import { generateActorKeys } from "./keys.js";
3
+ //#region src/bench/actor/fleet.ts
4
+ function httpStandardOf(standards) {
5
+ const http = standards.filter((s) => s === "draft-cavage-http-signatures-12" || s === "rfc9421");
6
+ if (http.length === 0) throw new TypeError("Every actor group must declare exactly one HTTP request signature standard.");
7
+ if (http.length > 1) throw new TypeError(`Every actor group must declare exactly one HTTP request signature standard, but multiple were given: ${http.join(", ")}.`);
8
+ return http[0];
9
+ }
10
+ /**
11
+ * Builds the fleet from the suite's actor groups, generating each actor's keys.
12
+ * When no groups are declared, a single default actor using
13
+ * `draft-cavage-http-signatures-12` is created.
14
+ * @param groups The suite's actor groups.
15
+ * @returns The fleet members, with keys generated.
16
+ */
17
+ async function buildFleet(groups) {
18
+ const effective = groups.length > 0 ? groups : [{ signatureStandards: ["draft-cavage-http-signatures-12"] }];
19
+ const members = [];
20
+ let index = 0;
21
+ for (const group of effective) {
22
+ const count = group.count ?? 1;
23
+ const standards = group.signatureStandards;
24
+ const httpStandard = httpStandardOf(standards);
25
+ for (let i = 0; i < count; i++) {
26
+ members.push({
27
+ index,
28
+ name: group.name,
29
+ standards,
30
+ keys: await generateActorKeys(standards),
31
+ httpStandard
32
+ });
33
+ index++;
34
+ }
35
+ }
36
+ return members;
37
+ }
38
+ //#endregion
39
+ export { buildFleet };
@@ -0,0 +1,35 @@
1
+ import "@js-temporal/polyfill";
2
+ import { generateCryptoKeyPair } from "@fedify/fedify";
3
+ //#region src/bench/actor/keys.ts
4
+ /**
5
+ * Key-pair generation for synthetic benchmark actors.
6
+ *
7
+ * An author picks signature standards, not key algorithms; the key set is
8
+ * derived from the chosen standards, mirroring how a real Fedify actor exposes
9
+ * keys. HTTP request signatures and LD Signatures share one RSA key pair;
10
+ * FEP-8b32 object integrity proofs use an Ed25519 key pair.
11
+ * @since 2.3.0
12
+ * @module
13
+ */
14
+ /** Whether a set of standards needs an RSA key pair. */
15
+ function needsRsa(standards) {
16
+ return standards.some((s) => s === "draft-cavage-http-signatures-12" || s === "rfc9421" || s === "ld-signatures");
17
+ }
18
+ /** Whether a set of standards needs an Ed25519 key pair. */
19
+ function needsEd25519(standards) {
20
+ return standards.includes("fep8b32");
21
+ }
22
+ /**
23
+ * Generates the key pairs an actor needs for its signature standards.
24
+ * @param standards The actor's signature standards.
25
+ * @returns The derived key pairs.
26
+ */
27
+ async function generateActorKeys(standards) {
28
+ const [rsa, ed25519] = await Promise.all([needsRsa(standards) ? generateCryptoKeyPair("RSASSA-PKCS1-v1_5") : Promise.resolve(void 0), needsEd25519(standards) ? generateCryptoKeyPair("Ed25519") : Promise.resolve(void 0)]);
29
+ return {
30
+ rsa,
31
+ ed25519
32
+ };
33
+ }
34
+ //#endregion
35
+ export { generateActorKeys };
@@ -0,0 +1,72 @@
1
+ import "@js-temporal/polyfill";
2
+ import { configContext } from "../config.js";
3
+ import { userAgentOption } from "../options.js";
4
+ import { argument, choice, command, constant, flag, group, merge, message, object, option, optional, or, string, withDefault } from "@optique/core";
5
+ import { bindConfig } from "@optique/config";
6
+ //#region src/bench/command.ts
7
+ const formatOption = bindConfig(option("-f", "--format", choice([
8
+ "text",
9
+ "json",
10
+ "markdown"
11
+ ], { metavar: "FORMAT" }), { description: message`The output format for the benchmark report.` }), {
12
+ context: configContext,
13
+ key: (config) => config.bench?.format ?? "text",
14
+ default: "text"
15
+ });
16
+ const allowUnsafeTarget = withDefault(flag("--allow-unsafe-target", { description: message`Allow benchmarking a public target that does not advertise \
17
+ benchmark mode. Must be given on the command line for each run; it cannot be \
18
+ set in a configuration file.` }), false);
19
+ const outputOption = optional(option("-o", "--output", string({ metavar: "OUTPUT_PATH" }), { description: message`Write the report to a file instead of standard output.` }));
20
+ const targetOption = optional(option("-t", "--target", string({ metavar: "URL" }), { description: message`Override the target URL declared in the suite.` }));
21
+ const advertiseHostOption = optional(option("--advertise-host", string({ metavar: "HOST" }), { description: message`Host (name or IP) a non-loopback target can reach the \
22
+ benchmark's synthetic actor server at. Required for signed scenarios against a \
23
+ non-loopback target; binds the synthetic server on all interfaces and uses this \
24
+ host in the actor and key URLs the target dereferences.` }));
25
+ const runParser = merge("Benchmark options", object({
26
+ command: constant("bench"),
27
+ mode: constant("run"),
28
+ scenario: group("Arguments", argument(string({ metavar: "SCENARIO_FILE" }), { description: message`Path to the benchmark suite file (YAML or JSON).` })),
29
+ target: targetOption,
30
+ format: formatOption,
31
+ output: outputOption,
32
+ dryRun: withDefault(flag("--dry-run", { description: message`Resolve discovery and print the benchmark plan without \
33
+ sending load.` }), false),
34
+ advertiseHost: advertiseHostOption,
35
+ allowUnsafeTarget
36
+ }), userAgentOption);
37
+ const benchOptions = or(command("compare", merge("Compare options", object({
38
+ command: constant("bench"),
39
+ mode: constant("compare"),
40
+ base: option("--base", string({ metavar: "REF" }), { description: message`The base git ref to benchmark.` }),
41
+ head: option("--head", string({ metavar: "REF" }), { description: message`The head git ref to benchmark.` }),
42
+ file: option("--file", string({ metavar: "SCENARIO_FILE" }), { description: message`Path to the benchmark suite file (YAML or JSON).` }),
43
+ startCommand: option("--start-command", string({ metavar: "COMMAND" }), { description: message`Shell command that starts the target application in each \
44
+ checked-out worktree.` }),
45
+ readyUrl: option("--ready-url", string({ metavar: "URL" }), { description: message`URL that returns success when the started target is ready.` }),
46
+ readyTimeout: withDefault(option("--ready-timeout", string({ metavar: "DURATION" }), { description: message`How long to wait for --ready-url.` }), "30s"),
47
+ maxRegression: option("--max-regression", string({ metavar: "PERCENT" }), { description: message`Maximum regression tolerated after the measured noise band.` }),
48
+ target: targetOption,
49
+ format: formatOption,
50
+ output: outputOption,
51
+ dryRun: constant(false),
52
+ advertiseHost: advertiseHostOption,
53
+ allowUnsafeTarget
54
+ }), userAgentOption), {
55
+ brief: message`Compare base and head benchmark runs.`,
56
+ description: message`Run the same benchmark suite against two git revisions on the \
57
+ same runner, then fail when the head revision regresses beyond the configured \
58
+ tolerance and measured noise band.`
59
+ }), runParser);
60
+ const benchMetadata = {
61
+ brief: message`Benchmark a Fedify federation workload.`,
62
+ description: message`Run an ActivityPub-specific load benchmark against a \
63
+ cooperative Fedify target running in benchmark mode.
64
+
65
+ The suite file declares the target, actors, and scenarios. This version \
66
+ executes the \`inbox\`, \`webfinger\`, \`actor\`, \`object\`, \`fanout\`, \
67
+ \`failure\`, and \`mixed\` scenario types; \`collection\` remains reserved by \
68
+ the suite format.`
69
+ };
70
+ command("bench", benchOptions, benchMetadata);
71
+ //#endregion
72
+ export { benchMetadata, benchOptions };
@@ -0,0 +1,16 @@
1
+ import "@js-temporal/polyfill";
2
+ //#region src/bench/compare/schema.ts
3
+ /**
4
+ * The embedded JSON Schema (draft 2020-12) for benchmark comparison output.
5
+ *
6
+ * The comparison report embeds the two benchmark reports it compares; this
7
+ * schema validates the comparison envelope and checks that the embedded reports
8
+ * look like current benchmark reports without duplicating the complete report
9
+ * schema in two published files.
10
+ * @since 2.3.0
11
+ * @module
12
+ */
13
+ /** The hosted URL that serves the comparison report schema. */
14
+ const COMPARE_REPORT_SCHEMA_ID = "https://json-schema.fedify.dev/bench/compare-report-v1.json";
15
+ //#endregion
16
+ export { COMPARE_REPORT_SCHEMA_ID };