@fedify/cli 2.3.0-dev.994 → 2.3.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/bench/action.js +469 -0
- package/dist/bench/actor/documents.js +39 -0
- package/dist/bench/actor/fleet.js +39 -0
- package/dist/bench/actor/keys.js +35 -0
- package/dist/bench/command.js +72 -0
- package/dist/bench/compare/schema.js +16 -0
- package/dist/bench/compare.js +667 -0
- package/dist/bench/discovery/discover.js +67 -0
- package/dist/bench/discovery/probe.js +50 -0
- package/dist/bench/load/arrival.js +27 -0
- package/dist/bench/load/clock.js +33 -0
- package/dist/bench/load/generator.js +145 -0
- package/dist/bench/metrics/aggregate.js +64 -0
- package/dist/bench/metrics/histogram.js +141 -0
- package/dist/bench/metrics/stats-client.js +216 -0
- package/dist/bench/mod.js +11 -0
- package/dist/bench/render/format.js +46 -0
- package/dist/bench/render/index.js +20 -0
- package/dist/bench/render/json.js +12 -0
- package/dist/bench/render/markdown.js +63 -0
- package/dist/bench/render/text.js +75 -0
- package/dist/bench/result/build.js +252 -0
- package/dist/bench/result/expect/assert.js +74 -0
- package/dist/bench/result/expect/evaluate.js +128 -0
- package/dist/bench/result/expect/metrics.js +34 -0
- package/dist/bench/result/schema.js +365 -0
- package/dist/bench/safety/gate.js +62 -0
- package/dist/bench/safety/tiers.js +97 -0
- package/dist/bench/scenario/coerce.js +24 -0
- package/dist/bench/scenario/errors.js +36 -0
- package/dist/bench/scenario/load.js +69 -0
- package/dist/bench/scenario/normalize.js +125 -0
- package/dist/bench/scenario/schema.js +399 -0
- package/dist/bench/scenario/units.js +56 -0
- package/dist/bench/scenario/validate.js +29 -0
- package/dist/bench/scenarios/actor.js +38 -0
- package/dist/bench/scenarios/failure.js +363 -0
- package/dist/bench/scenarios/fanout.js +261 -0
- package/dist/bench/scenarios/inbox.js +147 -0
- package/dist/bench/scenarios/mixed.js +244 -0
- package/dist/bench/scenarios/object-discovery.js +211 -0
- package/dist/bench/scenarios/object.js +54 -0
- package/dist/bench/scenarios/read.js +108 -0
- package/dist/bench/scenarios/registry.js +39 -0
- package/dist/bench/scenarios/runner.js +96 -0
- package/dist/bench/scenarios/webfinger.js +44 -0
- package/dist/bench/server/synthetic.js +118 -0
- package/dist/bench/signing/activity-id.js +18 -0
- package/dist/bench/signing/pipeline.js +134 -0
- package/dist/bench/signing/signer.js +39 -0
- package/dist/bench/template/generate.js +90 -0
- package/dist/bench/template/helpers.js +19 -0
- package/dist/bench/template/template.js +132 -0
- package/dist/cache.js +2 -2
- package/dist/commands.js +110 -0
- package/dist/config.js +15 -3
- package/dist/deno.js +1 -1
- package/dist/docloader.js +1 -1
- package/dist/generate-vocab/action.js +3 -3
- package/dist/generate-vocab/command.js +6 -4
- package/dist/imagerenderer.js +3 -3
- package/dist/inbox/command.js +6 -4
- package/dist/inbox/view.js +1 -1
- package/dist/inbox.js +4 -4
- package/dist/log.js +2 -2
- package/dist/lookup/command.js +121 -0
- package/dist/lookup.js +27 -138
- package/dist/mod.js +2 -20
- package/dist/nodeinfo.js +53 -12
- package/dist/options.js +1 -1
- package/dist/relay/command.js +6 -4
- package/dist/relay.js +3 -3
- package/dist/runner.js +70 -45
- package/dist/tunnel.js +8 -6
- package/dist/utils.js +9 -4
- package/dist/webfinger/action.js +1 -1
- package/dist/webfinger/command.js +6 -4
- package/dist/webfinger/error.js +2 -0
- package/dist/webfinger/lib.js +1 -1
- package/package.json +28 -23
- package/dist/generate-vocab/mod.js +0 -4
- package/dist/init/mod.js +0 -3
- 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 };
|