@fedify/cli 2.3.0-dev.1219 → 2.3.0-dev.1273
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 +309 -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 +42 -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 +15 -0
- package/dist/bench/load/generator.js +112 -0
- package/dist/bench/metrics/aggregate.js +64 -0
- package/dist/bench/metrics/histogram.js +141 -0
- package/dist/bench/metrics/stats-client.js +154 -0
- package/dist/bench/mod.js +4 -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 +62 -0
- package/dist/bench/render/text.js +74 -0
- package/dist/bench/result/build.js +129 -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 +15 -0
- package/dist/bench/safety/gate.js +60 -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 +126 -0
- package/dist/bench/scenario/schema.js +358 -0
- package/dist/bench/scenario/units.js +56 -0
- package/dist/bench/scenario/validate.js +29 -0
- package/dist/bench/scenarios/inbox.js +155 -0
- package/dist/bench/scenarios/registry.js +21 -0
- package/dist/bench/scenarios/runner.js +76 -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 +1 -1
- package/dist/config.js +14 -2
- package/dist/deno.js +1 -1
- package/dist/generate-vocab/action.js +3 -3
- package/dist/generate-vocab/command.js +1 -1
- package/dist/imagerenderer.js +1 -1
- package/dist/inbox/view.js +1 -1
- package/dist/inbox.js +2 -2
- package/dist/lookup.js +37 -37
- package/dist/mod.js +3 -0
- package/dist/nodeinfo.js +5 -5
- package/dist/options.js +1 -1
- package/dist/relay.js +1 -1
- package/dist/runner.js +10 -8
- package/dist/tempserver.js +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/utils.js +8 -4
- package/dist/webfinger/action.js +1 -1
- package/package.json +12 -10
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { getContextLoader, getDocumentLoader } from "../docloader.js";
|
|
3
|
+
import { describeError } from "../utils.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 { validateExpectBlock } from "./result/expect/evaluate.js";
|
|
8
|
+
import { buildReport, buildScenarioResult, configHash, detectEnvironment } from "./result/build.js";
|
|
9
|
+
import { probeBenchmarkMode } from "./discovery/probe.js";
|
|
10
|
+
import { renderReport } from "./render/index.js";
|
|
11
|
+
import { loadSuiteFile, renderSuiteTemplates } from "./scenario/load.js";
|
|
12
|
+
import { normalizeSuite } from "./scenario/normalize.js";
|
|
13
|
+
import { validateSuite } from "./scenario/validate.js";
|
|
14
|
+
import { UnsafeTargetError, assertInboxDestinationAllowed, assertTargetAllowed, assertUnsafeOverrideAllowed } from "./safety/gate.js";
|
|
15
|
+
import { classifyResolvedTarget } from "./safety/tiers.js";
|
|
16
|
+
import { runnerFor } from "./scenarios/registry.js";
|
|
17
|
+
import { resolveAdvertiseHost, spawnSyntheticServer } from "./server/synthetic.js";
|
|
18
|
+
import { writeFile } from "node:fs/promises";
|
|
19
|
+
import process from "node:process";
|
|
20
|
+
//#region src/bench/action.ts
|
|
21
|
+
/** The scenario types that need the synthetic actor/key server. */
|
|
22
|
+
const SIGNED_TYPES = new Set(["inbox"]);
|
|
23
|
+
/**
|
|
24
|
+
* Runs the `fedify bench` command: load and validate the suite, gate the
|
|
25
|
+
* target, run each scenario, and render the report. The process exits 0 when
|
|
26
|
+
* every `expect` gate passes and 1 otherwise; configuration and safety errors
|
|
27
|
+
* exit 2.
|
|
28
|
+
* @param command The parsed `bench` command options.
|
|
29
|
+
* @param deps Injectable dependencies for testing.
|
|
30
|
+
*/
|
|
31
|
+
async function runBench(command, deps = {}) {
|
|
32
|
+
const exit = deps.exit ?? ((code) => {
|
|
33
|
+
process.exitCode = code;
|
|
34
|
+
});
|
|
35
|
+
const writeOutput = deps.writeOutput ?? defaultWriteOutput;
|
|
36
|
+
const log = deps.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
37
|
+
const fetchImpl = withUserAgent(deps.fetch ?? fetch, command.userAgent);
|
|
38
|
+
let validated;
|
|
39
|
+
let suite;
|
|
40
|
+
try {
|
|
41
|
+
validated = validateSuite(renderSuiteTemplates(await loadSuiteFile(command.scenario), command.target), command.scenario);
|
|
42
|
+
suite = normalizeSuite(validated, { target: command.target });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
log(describeError(error));
|
|
45
|
+
exit(2);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let runners;
|
|
49
|
+
try {
|
|
50
|
+
runners = suite.scenarios.map((scenario) => {
|
|
51
|
+
const runner = runnerFor(scenario.type);
|
|
52
|
+
runner.validate?.(scenario);
|
|
53
|
+
validateExpectBlock(scenario.expect);
|
|
54
|
+
return runner;
|
|
55
|
+
});
|
|
56
|
+
if (command.advertiseHost != null) resolveAdvertiseHost(command.advertiseHost);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
log(describeError(error));
|
|
59
|
+
exit(2);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const tier = await classifyResolvedTarget(suite.target, deps.resolveTargetAddresses);
|
|
63
|
+
const probe = await probeBenchmarkMode(suite.target, fetchImpl);
|
|
64
|
+
try {
|
|
65
|
+
if (!command.dryRun) assertUnsafeOverrideAllowed({
|
|
66
|
+
tier,
|
|
67
|
+
benchmarkMode: probe.benchmarkMode,
|
|
68
|
+
allowUnsafe: command.allowUnsafeTarget,
|
|
69
|
+
explicitCliTarget: command.target != null,
|
|
70
|
+
scenarios: unsafeOverrideScenarios(validated)
|
|
71
|
+
});
|
|
72
|
+
assertTargetAllowed({
|
|
73
|
+
tier,
|
|
74
|
+
benchmarkMode: probe.benchmarkMode,
|
|
75
|
+
allowUnsafe: command.allowUnsafeTarget,
|
|
76
|
+
dryRun: command.dryRun
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof UnsafeTargetError) {
|
|
80
|
+
log(error.message);
|
|
81
|
+
exit(2);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const allowPrivateAddress = tier !== "public";
|
|
87
|
+
const documentLoader = await getDocumentLoader({
|
|
88
|
+
allowPrivateAddress,
|
|
89
|
+
userAgent: command.userAgent
|
|
90
|
+
});
|
|
91
|
+
const contextLoader = await getContextLoader({
|
|
92
|
+
allowPrivateAddress,
|
|
93
|
+
userAgent: command.userAgent
|
|
94
|
+
});
|
|
95
|
+
const assertDestinationAllowed = async (url, scenario) => {
|
|
96
|
+
const destinationTier = url.origin === suite.target.origin ? tier : await classifyResolvedTarget(url, deps.resolveTargetAddresses);
|
|
97
|
+
assertInboxDestinationAllowed(url, {
|
|
98
|
+
targetOrigin: suite.target.origin,
|
|
99
|
+
targetTier: tier,
|
|
100
|
+
destinationTier,
|
|
101
|
+
targetBenchmarkMode: probe.benchmarkMode,
|
|
102
|
+
allowUnsafe: command.allowUnsafeTarget,
|
|
103
|
+
advertised: command.advertiseHost != null
|
|
104
|
+
});
|
|
105
|
+
assertPublicDestinationOverrideAllowed(url, scenario, {
|
|
106
|
+
targetOrigin: suite.target.origin,
|
|
107
|
+
targetBenchmarkMode: probe.benchmarkMode,
|
|
108
|
+
allowUnsafe: command.allowUnsafeTarget,
|
|
109
|
+
explicitCliTarget: command.target != null,
|
|
110
|
+
destinationTier,
|
|
111
|
+
defaults: validated.defaults
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
if (command.dryRun) try {
|
|
115
|
+
await writeOutput(await renderPlan(suite, {
|
|
116
|
+
documentLoader,
|
|
117
|
+
contextLoader,
|
|
118
|
+
allowPrivateAddress,
|
|
119
|
+
assertDestinationAllowed
|
|
120
|
+
}), command.output);
|
|
121
|
+
exit(0);
|
|
122
|
+
return;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
log(describeError(error));
|
|
125
|
+
exit(2);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (tier !== "loopback" && command.advertiseHost == null && suite.scenarios.some((s) => SIGNED_TYPES.has(s.type))) {
|
|
129
|
+
log("Signed scenarios (inbox) need the benchmark's synthetic actor server to be reachable from the target. A loopback target reaches it automatically; for a non-loopback target, pass --advertise-host with an address the target can reach (the synthetic server then binds all interfaces), or use a read scenario such as webfinger.");
|
|
130
|
+
exit(2);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let fleet;
|
|
134
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
135
|
+
try {
|
|
136
|
+
if (suite.scenarios.some((s) => SIGNED_TYPES.has(s.type))) fleet = await spawnSyntheticServer(await buildFleet(suite.actors), { advertiseHost: command.advertiseHost });
|
|
137
|
+
const results = [];
|
|
138
|
+
for (let i = 0; i < suite.scenarios.length; i++) {
|
|
139
|
+
const scenario = suite.scenarios[i];
|
|
140
|
+
log(`Running scenario "${scenario.name}" (${scenario.type})…`);
|
|
141
|
+
const measurement = await runners[i].run({
|
|
142
|
+
scenario,
|
|
143
|
+
target: suite.target,
|
|
144
|
+
documentLoader,
|
|
145
|
+
contextLoader,
|
|
146
|
+
allowPrivateAddress,
|
|
147
|
+
fleet: fleet ?? null,
|
|
148
|
+
fetch: fetchImpl,
|
|
149
|
+
assertDestinationAllowed: (url) => assertDestinationAllowed(url, scenario)
|
|
150
|
+
});
|
|
151
|
+
results.push(buildScenarioResult(scenario, measurement));
|
|
152
|
+
}
|
|
153
|
+
const report = buildReport({
|
|
154
|
+
scenarios: results,
|
|
155
|
+
environment: detectEnvironment(),
|
|
156
|
+
target: {
|
|
157
|
+
url: suite.target.href,
|
|
158
|
+
fedifyVersion: probe.fedifyVersion,
|
|
159
|
+
statsAvailable: probe.benchmarkMode
|
|
160
|
+
},
|
|
161
|
+
startedAt,
|
|
162
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
163
|
+
suite: { configHash: configHash({
|
|
164
|
+
suite: validated,
|
|
165
|
+
target: suite.target.href
|
|
166
|
+
}) }
|
|
167
|
+
});
|
|
168
|
+
await writeOutput(renderReport(report, command.format), command.output);
|
|
169
|
+
exit(report.passed ? 0 : 1);
|
|
170
|
+
return;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof UnsafeTargetError) {
|
|
173
|
+
log(error.message);
|
|
174
|
+
exit(2);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
} finally {
|
|
179
|
+
await fleet?.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Wraps a fetch implementation so every request carries the given User-Agent,
|
|
184
|
+
* unless the caller already set one. A prebuilt {@link Request} (the signed
|
|
185
|
+
* inbox delivery, a WebFinger GET) is mutated in place rather than recloned, so
|
|
186
|
+
* an already-signed body and its digest are left untouched; the User-Agent is
|
|
187
|
+
* not part of the signed header set, so adding it does not affect verification.
|
|
188
|
+
* @param fetchImpl The underlying fetch implementation.
|
|
189
|
+
* @param userAgent The User-Agent header value to apply.
|
|
190
|
+
* @returns A fetch implementation that injects the User-Agent.
|
|
191
|
+
*/
|
|
192
|
+
function withUserAgent(fetchImpl, userAgent) {
|
|
193
|
+
return ((input, init) => {
|
|
194
|
+
if (input instanceof Request && init === void 0) {
|
|
195
|
+
if (input.headers.has("user-agent")) return fetchImpl(input);
|
|
196
|
+
try {
|
|
197
|
+
input.headers.set("user-agent", userAgent);
|
|
198
|
+
return fetchImpl(input);
|
|
199
|
+
} catch {
|
|
200
|
+
const headers = new Headers(input.headers);
|
|
201
|
+
headers.set("user-agent", userAgent);
|
|
202
|
+
return fetchImpl(new Request(input, { headers }));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
206
|
+
if (!headers.has("user-agent")) headers.set("user-agent", userAgent);
|
|
207
|
+
return fetchImpl(input, {
|
|
208
|
+
...init,
|
|
209
|
+
headers
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async function defaultWriteOutput(content, outputPath) {
|
|
214
|
+
if (outputPath == null) {
|
|
215
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await writeFile(outputPath, content, { encoding: "utf-8" });
|
|
219
|
+
}
|
|
220
|
+
async function renderPlan(suite, context) {
|
|
221
|
+
const lines = [
|
|
222
|
+
"Fedify benchmark plan (dry run)",
|
|
223
|
+
"",
|
|
224
|
+
`Target: ${suite.target.href}`,
|
|
225
|
+
""
|
|
226
|
+
];
|
|
227
|
+
for (const scenario of suite.scenarios) {
|
|
228
|
+
lines.push(`- ${scenario.name} (${scenario.type}): ${describePlan(scenario)}`);
|
|
229
|
+
lines.push(...await describeDiscoveryPlan(scenario, suite, context));
|
|
230
|
+
}
|
|
231
|
+
lines.push("", "No benchmark load was sent. Discovery and stats probe requests may have been sent.");
|
|
232
|
+
return `${lines.join("\n")}\n`;
|
|
233
|
+
}
|
|
234
|
+
function describePlan(scenario) {
|
|
235
|
+
return `${scenario.load.kind === "open" ? `open-loop ${scenario.load.ratePerSec}/s ${scenario.load.arrival}` : `closed-loop concurrency ${scenario.load.concurrency}`}, duration ${scenario.durationMs}ms, signing ${scenario.signing}`;
|
|
236
|
+
}
|
|
237
|
+
async function describeDiscoveryPlan(scenario, suite, context) {
|
|
238
|
+
switch (scenario.type) {
|
|
239
|
+
case "inbox": return await describeInboxDiscoveryPlan(scenario, context);
|
|
240
|
+
case "webfinger": return describeWebFingerPlan(scenario, suite.target);
|
|
241
|
+
default: return [" discovery: not available for this scenario type"];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async function describeInboxDiscoveryPlan(scenario, context) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
for (const recipient of scenario.recipients) {
|
|
247
|
+
let discovered;
|
|
248
|
+
try {
|
|
249
|
+
discovered = await discoverInbox(recipient, {
|
|
250
|
+
documentLoader: context.documentLoader,
|
|
251
|
+
contextLoader: context.contextLoader,
|
|
252
|
+
allowPrivateAddress: context.allowPrivateAddress
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
lines.push(` recipient ${recipient}: discovery failed (${describeError(error)})`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const inbox = selectInbox(discovered, scenario.inbox);
|
|
259
|
+
lines.push(` recipient ${recipient}: actor ${discovered.actorUri.href}, inbox ${inbox.href}`);
|
|
260
|
+
lines.push(` destination safety: ${await describeDestinationSafety(inbox, scenario, context)}`);
|
|
261
|
+
}
|
|
262
|
+
return lines;
|
|
263
|
+
}
|
|
264
|
+
function describeWebFingerPlan(scenario, target) {
|
|
265
|
+
return (scenario.recipients.length > 0 ? scenario.recipients : [target.href]).map((recipient) => {
|
|
266
|
+
const resource = convertUrlIfHandle(recipient).href;
|
|
267
|
+
const url = new URL("/.well-known/webfinger", target);
|
|
268
|
+
url.searchParams.set("resource", resource);
|
|
269
|
+
return ` webfinger ${resource}: GET ${url.href}`;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
async function describeDestinationSafety(inbox, scenario, context) {
|
|
273
|
+
try {
|
|
274
|
+
await context.assertDestinationAllowed(inbox, scenario);
|
|
275
|
+
return "allowed";
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error instanceof UnsafeTargetError) return `would be refused: ${error.message}`;
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function assertPublicDestinationOverrideAllowed(url, scenario, context) {
|
|
282
|
+
const inheritsTargetGate = url.origin === context.targetOrigin && context.targetBenchmarkMode;
|
|
283
|
+
if (context.destinationTier !== "public" || inheritsTargetGate || !context.allowUnsafe) return;
|
|
284
|
+
assertUnsafeOverrideAllowed({
|
|
285
|
+
tier: "public",
|
|
286
|
+
benchmarkMode: false,
|
|
287
|
+
allowUnsafe: true,
|
|
288
|
+
explicitCliTarget: context.explicitCliTarget,
|
|
289
|
+
scenarios: [unsafeOverrideScenario(scenario, context.defaults)]
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function unsafeOverrideScenarios(suite) {
|
|
293
|
+
return suite.scenarios.map((scenario) => unsafeOverrideScenario(scenario, suite.defaults));
|
|
294
|
+
}
|
|
295
|
+
function unsafeOverrideScenario(scenario, defaults) {
|
|
296
|
+
const defaultDuration = defaults?.duration != null;
|
|
297
|
+
const defaultLoad = hasExplicitLoad(defaults?.load);
|
|
298
|
+
const raw = "raw" in scenario ? scenario.raw : scenario;
|
|
299
|
+
return {
|
|
300
|
+
name: scenario.name,
|
|
301
|
+
explicitDuration: raw.duration != null || defaultDuration,
|
|
302
|
+
explicitLoad: hasExplicitLoad(raw.load) || defaultLoad
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function hasExplicitLoad(load) {
|
|
306
|
+
return load != null && typeof load === "object" && ("rate" in load && load.rate != null || "concurrency" in load && load.concurrency != null);
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
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,42 @@
|
|
|
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, 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 benchCommand = command("bench", merge("Benchmark options", object({
|
|
20
|
+
command: constant("bench"),
|
|
21
|
+
scenario: group("Arguments", argument(string({ metavar: "SCENARIO_FILE" }), { description: message`Path to the benchmark suite file (YAML or JSON).` })),
|
|
22
|
+
target: optional(option("-t", "--target", string({ metavar: "URL" }), { description: message`Override the target URL declared in the suite.` })),
|
|
23
|
+
format: formatOption,
|
|
24
|
+
output: optional(option("-o", "--output", string({ metavar: "OUTPUT_PATH" }), { description: message`Write the report to a file instead of standard output.` })),
|
|
25
|
+
dryRun: withDefault(flag("--dry-run", { description: message`Resolve discovery and print the benchmark plan without \
|
|
26
|
+
sending load.` }), false),
|
|
27
|
+
advertiseHost: optional(option("--advertise-host", string({ metavar: "HOST" }), { description: message`Host (name or IP) a non-loopback target can reach the \
|
|
28
|
+
benchmark's synthetic actor server at. Required for signed scenarios against a \
|
|
29
|
+
non-loopback target; binds the synthetic server on all interfaces and uses this \
|
|
30
|
+
host in the actor and key URLs the target dereferences.` })),
|
|
31
|
+
allowUnsafeTarget
|
|
32
|
+
}), userAgentOption), {
|
|
33
|
+
brief: message`Benchmark a Fedify federation workload.`,
|
|
34
|
+
description: message`Run an ActivityPub-specific load benchmark against a \
|
|
35
|
+
cooperative Fedify target running in benchmark mode.
|
|
36
|
+
|
|
37
|
+
The suite file declares the target, actors, and scenarios. Only the \`inbox\` \
|
|
38
|
+
and \`webfinger\` scenario types are executed in this version; the format \
|
|
39
|
+
itself can express every scenario type.`
|
|
40
|
+
});
|
|
41
|
+
//#endregion
|
|
42
|
+
export { benchCommand };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { getContextLoader, getDocumentLoader } from "../../docloader.js";
|
|
3
|
+
import { convertUrlIfHandle } from "../../webfinger/lib.js";
|
|
4
|
+
import { isActor, lookupObject } from "@fedify/vocab";
|
|
5
|
+
//#region src/bench/discovery/discover.ts
|
|
6
|
+
/**
|
|
7
|
+
* Recipient discovery: resolving a handle or actor URI to the inbox URL a real
|
|
8
|
+
* peer would deliver to.
|
|
9
|
+
*
|
|
10
|
+
* Discovery mirrors how a remote server finds an inbox: WebFinger on a handle
|
|
11
|
+
* yields the actor URI, then the actor document yields its personal `inbox` and
|
|
12
|
+
* its shared inbox endpoint. `lookupObject()` performs the WebFinger step for
|
|
13
|
+
* `acct:` identifiers automatically.
|
|
14
|
+
* @since 2.3.0
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
/** An error raised when a recipient cannot be discovered. */
|
|
18
|
+
var DiscoveryError = class extends Error {};
|
|
19
|
+
/**
|
|
20
|
+
* Discovers a recipient's inbox URLs from a handle or actor URI.
|
|
21
|
+
* @param recipient A handle (`acct:alice@host` or `@alice@host`) or actor URI.
|
|
22
|
+
* @param options Document/context loaders (use a private-address-allowing
|
|
23
|
+
* loader for loopback targets).
|
|
24
|
+
* @returns The actor URI and its personal and shared inbox URLs.
|
|
25
|
+
* @throws {DiscoveryError} If the recipient does not resolve to an actor with
|
|
26
|
+
* an inbox.
|
|
27
|
+
*/
|
|
28
|
+
async function discoverInbox(recipient, options = {}) {
|
|
29
|
+
const identifier = convertUrlIfHandle(recipient);
|
|
30
|
+
const { lookup = lookupObject, allowPrivateAddress } = options;
|
|
31
|
+
const documentLoader = options.documentLoader ?? (allowPrivateAddress ? await getDocumentLoader({ allowPrivateAddress: true }) : void 0);
|
|
32
|
+
const contextLoader = options.contextLoader ?? (allowPrivateAddress ? await getContextLoader({ allowPrivateAddress: true }) : void 0);
|
|
33
|
+
let object;
|
|
34
|
+
try {
|
|
35
|
+
object = await lookup(identifier, {
|
|
36
|
+
documentLoader,
|
|
37
|
+
contextLoader,
|
|
38
|
+
allowPrivateAddress
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new DiscoveryError(`Failed to resolve recipient ${recipient}: ${error}`);
|
|
42
|
+
}
|
|
43
|
+
if (!isActor(object)) throw new DiscoveryError(`Recipient ${recipient} did not resolve to an actor.`);
|
|
44
|
+
if (object.inboxId == null) throw new DiscoveryError(`Actor ${recipient} has no inbox.`);
|
|
45
|
+
return {
|
|
46
|
+
actorUri: object.id ?? identifier,
|
|
47
|
+
personalInbox: object.inboxId,
|
|
48
|
+
sharedInbox: object.endpoints?.sharedInbox ?? null
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Chooses the inbox URL to deliver to for a scenario's `inbox` mode.
|
|
53
|
+
*
|
|
54
|
+
* `"shared"` (the default) prefers the shared inbox and falls back to the
|
|
55
|
+
* personal one; `"personal"` uses the personal inbox; any other value is an
|
|
56
|
+
* explicit inbox URL that skips discovery selection.
|
|
57
|
+
* @param discovered The discovered inbox URLs.
|
|
58
|
+
* @param mode The scenario's `inbox` value.
|
|
59
|
+
* @returns The inbox URL to deliver to.
|
|
60
|
+
*/
|
|
61
|
+
function selectInbox(discovered, mode) {
|
|
62
|
+
if (mode != null && mode !== "shared" && mode !== "personal") return new URL(mode);
|
|
63
|
+
if (mode === "personal") return discovered.personalInbox;
|
|
64
|
+
return discovered.sharedInbox ?? discovered.personalInbox;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { discoverInbox, selectInbox };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/discovery/probe.ts
|
|
3
|
+
/** The path of the cooperative benchmark stats endpoint. */
|
|
4
|
+
const STATS_PATH = "/.well-known/fedify/bench/stats";
|
|
5
|
+
/**
|
|
6
|
+
* Probes a target for benchmark mode.
|
|
7
|
+
* @param target The target base URL.
|
|
8
|
+
* @param fetchImpl The fetch implementation (overridable for tests).
|
|
9
|
+
* @returns Whether benchmark mode is advertised and the target's Fedify
|
|
10
|
+
* version. Never throws; a failed probe reports `benchmarkMode:
|
|
11
|
+
* false`.
|
|
12
|
+
*/
|
|
13
|
+
async function probeBenchmarkMode(target, fetchImpl = fetch) {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetchImpl(new URL(STATS_PATH, target), {
|
|
16
|
+
headers: { accept: "application/json" },
|
|
17
|
+
redirect: "manual"
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) return notAdvertised();
|
|
20
|
+
const json = await response.json();
|
|
21
|
+
if (json?.version === 1 && json?.source === "server") return {
|
|
22
|
+
benchmarkMode: true,
|
|
23
|
+
fedifyVersion: extractFedifyVersion(json)
|
|
24
|
+
};
|
|
25
|
+
return notAdvertised();
|
|
26
|
+
} catch {
|
|
27
|
+
return notAdvertised();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function notAdvertised() {
|
|
31
|
+
return {
|
|
32
|
+
benchmarkMode: false,
|
|
33
|
+
fedifyVersion: null
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function extractFedifyVersion(json) {
|
|
37
|
+
try {
|
|
38
|
+
const scopes = Array.isArray(json.scopeMetrics) ? json.scopeMetrics : [];
|
|
39
|
+
for (const entry of scopes) {
|
|
40
|
+
if (entry == null || typeof entry !== "object") continue;
|
|
41
|
+
const descriptor = entry.scope;
|
|
42
|
+
if (descriptor == null || typeof descriptor !== "object") continue;
|
|
43
|
+
const { name, version } = descriptor;
|
|
44
|
+
if (name === "@fedify/fedify") return typeof version === "string" ? version : null;
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { STATS_PATH, probeBenchmarkMode };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/load/arrival.ts
|
|
3
|
+
/**
|
|
4
|
+
* Lazily yields the scheduled arrival offsets (milliseconds from the start) for
|
|
5
|
+
* a load run. Yielding rather than materializing keeps memory flat for long,
|
|
6
|
+
* high-rate runs.
|
|
7
|
+
* @param options The scheduling options.
|
|
8
|
+
* @yields Arrival offsets within `[0, durationMs)`, in increasing order.
|
|
9
|
+
*/
|
|
10
|
+
function* scheduleArrivals(options) {
|
|
11
|
+
const { ratePerSec, durationMs, arrival } = options;
|
|
12
|
+
if (ratePerSec <= 0 || durationMs <= 0) return;
|
|
13
|
+
const meanGapMs = 1e3 / ratePerSec;
|
|
14
|
+
if (arrival === "constant") {
|
|
15
|
+
for (let t = 0; t < durationMs; t += meanGapMs) yield t;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const rng = options.rng ?? Math.random;
|
|
19
|
+
let t = 0;
|
|
20
|
+
for (;;) {
|
|
21
|
+
t += -Math.log(1 - rng()) * meanGapMs;
|
|
22
|
+
if (t >= durationMs) break;
|
|
23
|
+
yield t;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { scheduleArrivals };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/load/clock.ts
|
|
3
|
+
/** Returns a clock backed by `performance.now()` and `setTimeout`. */
|
|
4
|
+
function systemClock() {
|
|
5
|
+
return {
|
|
6
|
+
now: () => performance.now(),
|
|
7
|
+
sleepUntil(timeMs) {
|
|
8
|
+
const remaining = timeMs - performance.now();
|
|
9
|
+
if (remaining <= 0) return Promise.resolve();
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, remaining));
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { systemClock };
|