@fedify/cli 2.3.0-dev.1214 → 2.3.0-dev.1258

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 (61) hide show
  1. package/dist/bench/action.js +203 -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 +42 -0
  6. package/dist/bench/discovery/discover.js +67 -0
  7. package/dist/bench/discovery/probe.js +50 -0
  8. package/dist/bench/load/arrival.js +27 -0
  9. package/dist/bench/load/clock.js +15 -0
  10. package/dist/bench/load/generator.js +112 -0
  11. package/dist/bench/metrics/aggregate.js +64 -0
  12. package/dist/bench/metrics/histogram.js +141 -0
  13. package/dist/bench/metrics/stats-client.js +154 -0
  14. package/dist/bench/mod.js +4 -0
  15. package/dist/bench/render/format.js +46 -0
  16. package/dist/bench/render/index.js +20 -0
  17. package/dist/bench/render/json.js +12 -0
  18. package/dist/bench/render/markdown.js +62 -0
  19. package/dist/bench/render/text.js +74 -0
  20. package/dist/bench/result/build.js +129 -0
  21. package/dist/bench/result/expect/assert.js +74 -0
  22. package/dist/bench/result/expect/evaluate.js +128 -0
  23. package/dist/bench/result/expect/metrics.js +34 -0
  24. package/dist/bench/result/schema.js +15 -0
  25. package/dist/bench/safety/gate.js +54 -0
  26. package/dist/bench/safety/tiers.js +41 -0
  27. package/dist/bench/scenario/coerce.js +24 -0
  28. package/dist/bench/scenario/errors.js +36 -0
  29. package/dist/bench/scenario/load.js +69 -0
  30. package/dist/bench/scenario/normalize.js +126 -0
  31. package/dist/bench/scenario/schema.js +358 -0
  32. package/dist/bench/scenario/units.js +56 -0
  33. package/dist/bench/scenario/validate.js +29 -0
  34. package/dist/bench/scenarios/inbox.js +155 -0
  35. package/dist/bench/scenarios/registry.js +21 -0
  36. package/dist/bench/scenarios/runner.js +76 -0
  37. package/dist/bench/scenarios/webfinger.js +44 -0
  38. package/dist/bench/server/synthetic.js +118 -0
  39. package/dist/bench/signing/activity-id.js +18 -0
  40. package/dist/bench/signing/pipeline.js +134 -0
  41. package/dist/bench/signing/signer.js +39 -0
  42. package/dist/bench/template/generate.js +90 -0
  43. package/dist/bench/template/helpers.js +19 -0
  44. package/dist/bench/template/template.js +132 -0
  45. package/dist/cache.js +1 -1
  46. package/dist/config.js +14 -2
  47. package/dist/deno.js +1 -1
  48. package/dist/generate-vocab/action.js +3 -3
  49. package/dist/generate-vocab/command.js +1 -1
  50. package/dist/imagerenderer.js +1 -1
  51. package/dist/inbox.js +1 -1
  52. package/dist/lookup.js +34 -34
  53. package/dist/mod.js +3 -0
  54. package/dist/nodeinfo.js +6 -6
  55. package/dist/options.js +1 -1
  56. package/dist/runner.js +9 -8
  57. package/dist/tempserver.js +1 -1
  58. package/dist/tunnel.js +2 -2
  59. package/dist/utils.js +3 -2
  60. package/dist/webfinger/action.js +2 -2
  61. package/package.json +12 -10
@@ -0,0 +1,203 @@
1
+ import "@js-temporal/polyfill";
2
+ import { getContextLoader, getDocumentLoader } from "../docloader.js";
3
+ import { buildFleet } from "./actor/fleet.js";
4
+ import { validateExpectBlock } from "./result/expect/evaluate.js";
5
+ import { buildReport, buildScenarioResult, configHash, detectEnvironment } from "./result/build.js";
6
+ import { probeBenchmarkMode } from "./discovery/probe.js";
7
+ import { renderReport } from "./render/index.js";
8
+ import { loadSuiteFile, renderSuiteTemplates } from "./scenario/load.js";
9
+ import { normalizeSuite } from "./scenario/normalize.js";
10
+ import { validateSuite } from "./scenario/validate.js";
11
+ import { classifyTarget } from "./safety/tiers.js";
12
+ import { UnsafeTargetError, assertInboxDestinationAllowed, assertTargetAllowed } from "./safety/gate.js";
13
+ import { runnerFor } from "./scenarios/registry.js";
14
+ import { resolveAdvertiseHost, spawnSyntheticServer } from "./server/synthetic.js";
15
+ import { writeFile } from "node:fs/promises";
16
+ import process from "node:process";
17
+ //#region src/bench/action.ts
18
+ /** The scenario types that need the synthetic actor/key server. */
19
+ const SIGNED_TYPES = new Set(["inbox"]);
20
+ /**
21
+ * Runs the `fedify bench` command: load and validate the suite, gate the
22
+ * target, run each scenario, and render the report. The process exits 0 when
23
+ * every `expect` gate passes and 1 otherwise; configuration and safety errors
24
+ * exit 2.
25
+ * @param command The parsed `bench` command options.
26
+ * @param deps Injectable dependencies for testing.
27
+ */
28
+ async function runBench(command, deps = {}) {
29
+ const exit = deps.exit ?? ((code) => {
30
+ process.exitCode = code;
31
+ });
32
+ const writeOutput = deps.writeOutput ?? defaultWriteOutput;
33
+ const log = deps.log ?? ((message) => process.stderr.write(`${message}\n`));
34
+ const fetchImpl = withUserAgent(deps.fetch ?? fetch, command.userAgent);
35
+ let validated;
36
+ let suite;
37
+ try {
38
+ validated = validateSuite(renderSuiteTemplates(await loadSuiteFile(command.scenario), command.target), command.scenario);
39
+ suite = normalizeSuite(validated, { target: command.target });
40
+ } catch (error) {
41
+ log(error instanceof Error ? error.message : String(error));
42
+ exit(2);
43
+ return;
44
+ }
45
+ let runners;
46
+ try {
47
+ runners = suite.scenarios.map((scenario) => {
48
+ const runner = runnerFor(scenario.type);
49
+ runner.validate?.(scenario);
50
+ validateExpectBlock(scenario.expect);
51
+ return runner;
52
+ });
53
+ if (command.advertiseHost != null) resolveAdvertiseHost(command.advertiseHost);
54
+ } catch (error) {
55
+ log(error instanceof Error ? error.message : String(error));
56
+ exit(2);
57
+ return;
58
+ }
59
+ if (command.dryRun) {
60
+ await writeOutput(renderPlan(suite), command.output);
61
+ exit(0);
62
+ return;
63
+ }
64
+ const tier = classifyTarget(suite.target);
65
+ const probe = await probeBenchmarkMode(suite.target, fetchImpl);
66
+ try {
67
+ assertTargetAllowed({
68
+ tier,
69
+ benchmarkMode: probe.benchmarkMode,
70
+ allowUnsafe: command.allowUnsafeTarget,
71
+ dryRun: false
72
+ });
73
+ } catch (error) {
74
+ if (error instanceof UnsafeTargetError) {
75
+ log(error.message);
76
+ exit(2);
77
+ return;
78
+ }
79
+ throw error;
80
+ }
81
+ if (tier !== "loopback" && command.advertiseHost == null && suite.scenarios.some((s) => SIGNED_TYPES.has(s.type))) {
82
+ 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.");
83
+ exit(2);
84
+ return;
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 = (url) => assertInboxDestinationAllowed(url, {
96
+ targetOrigin: suite.target.origin,
97
+ targetBenchmarkMode: probe.benchmarkMode,
98
+ allowUnsafe: command.allowUnsafeTarget,
99
+ advertised: command.advertiseHost != null
100
+ });
101
+ let fleet;
102
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
103
+ try {
104
+ if (suite.scenarios.some((s) => SIGNED_TYPES.has(s.type))) fleet = await spawnSyntheticServer(await buildFleet(suite.actors), { advertiseHost: command.advertiseHost });
105
+ const results = [];
106
+ for (let i = 0; i < suite.scenarios.length; i++) {
107
+ const scenario = suite.scenarios[i];
108
+ log(`Running scenario "${scenario.name}" (${scenario.type})…`);
109
+ const measurement = await runners[i].run({
110
+ scenario,
111
+ target: suite.target,
112
+ documentLoader,
113
+ contextLoader,
114
+ allowPrivateAddress,
115
+ fleet: fleet ?? null,
116
+ fetch: fetchImpl,
117
+ assertDestinationAllowed
118
+ });
119
+ results.push(buildScenarioResult(scenario, measurement));
120
+ }
121
+ const report = buildReport({
122
+ scenarios: results,
123
+ environment: detectEnvironment(),
124
+ target: {
125
+ url: suite.target.href,
126
+ fedifyVersion: probe.fedifyVersion,
127
+ statsAvailable: probe.benchmarkMode
128
+ },
129
+ startedAt,
130
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
131
+ suite: { configHash: configHash({
132
+ suite: validated,
133
+ target: suite.target.href
134
+ }) }
135
+ });
136
+ await writeOutput(renderReport(report, command.format), command.output);
137
+ exit(report.passed ? 0 : 1);
138
+ return;
139
+ } catch (error) {
140
+ if (error instanceof UnsafeTargetError) {
141
+ log(error.message);
142
+ exit(2);
143
+ return;
144
+ }
145
+ throw error;
146
+ } finally {
147
+ await fleet?.close();
148
+ }
149
+ }
150
+ /**
151
+ * Wraps a fetch implementation so every request carries the given User-Agent,
152
+ * unless the caller already set one. A prebuilt {@link Request} (the signed
153
+ * inbox delivery, a WebFinger GET) is mutated in place rather than recloned, so
154
+ * an already-signed body and its digest are left untouched; the User-Agent is
155
+ * not part of the signed header set, so adding it does not affect verification.
156
+ * @param fetchImpl The underlying fetch implementation.
157
+ * @param userAgent The User-Agent header value to apply.
158
+ * @returns A fetch implementation that injects the User-Agent.
159
+ */
160
+ function withUserAgent(fetchImpl, userAgent) {
161
+ return ((input, init) => {
162
+ if (input instanceof Request && init === void 0) {
163
+ if (input.headers.has("user-agent")) return fetchImpl(input);
164
+ try {
165
+ input.headers.set("user-agent", userAgent);
166
+ return fetchImpl(input);
167
+ } catch {
168
+ const headers = new Headers(input.headers);
169
+ headers.set("user-agent", userAgent);
170
+ return fetchImpl(new Request(input, { headers }));
171
+ }
172
+ }
173
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
174
+ if (!headers.has("user-agent")) headers.set("user-agent", userAgent);
175
+ return fetchImpl(input, {
176
+ ...init,
177
+ headers
178
+ });
179
+ });
180
+ }
181
+ async function defaultWriteOutput(content, outputPath) {
182
+ if (outputPath == null) {
183
+ process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
184
+ return;
185
+ }
186
+ await writeFile(outputPath, content, { encoding: "utf-8" });
187
+ }
188
+ function renderPlan(suite) {
189
+ const lines = [
190
+ "Fedify benchmark plan (dry run)",
191
+ "",
192
+ `Target: ${suite.target.href}`,
193
+ ""
194
+ ];
195
+ for (const scenario of suite.scenarios) lines.push(`- ${scenario.name} (${scenario.type}): ${describePlan(scenario)}`);
196
+ lines.push("", "No requests were sent.");
197
+ return `${lines.join("\n")}\n`;
198
+ }
199
+ function describePlan(scenario) {
200
+ 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}`;
201
+ }
202
+ //#endregion
203
+ 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`Print the normalized plan without contacting the target or \
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 };
@@ -0,0 +1,112 @@
1
+ import "@js-temporal/polyfill";
2
+ import { scheduleArrivals } from "./arrival.js";
3
+ import { systemClock } from "./clock.js";
4
+ //#region src/bench/load/generator.ts
5
+ /**
6
+ * Runs a load plan against a send function.
7
+ * @param plan The load plan.
8
+ * @param send The function that performs one send.
9
+ * @param clock The clock (overridable for tests); defaults to the system clock.
10
+ * @returns The recorded samples and run metadata.
11
+ */
12
+ function runLoad(plan, send, clock = systemClock()) {
13
+ return plan.load.kind === "open" ? runOpenLoop(plan, plan.load, send, clock) : runClosedLoop(plan, plan.load, send, clock);
14
+ }
15
+ async function runOpenLoop(plan, load, send, clock) {
16
+ const arrivals = scheduleArrivals({
17
+ ratePerSec: load.ratePerSec,
18
+ durationMs: plan.durationMs,
19
+ arrival: load.arrival,
20
+ rng: plan.rng
21
+ });
22
+ const samples = [];
23
+ const slots = createSemaphore(load.maxInFlight);
24
+ let saturated = false;
25
+ const start = clock.now();
26
+ const active = /* @__PURE__ */ new Set();
27
+ for (const offset of arrivals) {
28
+ await clock.sleepUntil(start + offset);
29
+ if (await slots.acquire()) saturated = true;
30
+ const dispatched = dispatch(send, offset, start, plan.warmupMs, clock, samples).finally(() => {
31
+ slots.release();
32
+ active.delete(dispatched);
33
+ });
34
+ active.add(dispatched);
35
+ }
36
+ await Promise.all(active);
37
+ return {
38
+ samples,
39
+ saturated,
40
+ wallDurationMs: clock.now() - start
41
+ };
42
+ }
43
+ async function runClosedLoop(plan, load, send, clock) {
44
+ const samples = [];
45
+ const slots = createSemaphore(load.maxInFlight);
46
+ let saturated = false;
47
+ const start = clock.now();
48
+ const deadline = start + plan.durationMs;
49
+ async function worker() {
50
+ while (clock.now() < deadline) {
51
+ if (await slots.acquire()) saturated = true;
52
+ if (clock.now() >= deadline) {
53
+ slots.release();
54
+ break;
55
+ }
56
+ const offset = clock.now() - start;
57
+ try {
58
+ await dispatch(send, offset, start, plan.warmupMs, clock, samples);
59
+ } finally {
60
+ slots.release();
61
+ }
62
+ }
63
+ }
64
+ await Promise.all(Array.from({ length: load.concurrency }, () => worker()));
65
+ return {
66
+ samples,
67
+ saturated,
68
+ wallDurationMs: clock.now() - start
69
+ };
70
+ }
71
+ async function dispatch(send, offset, start, warmupMs, clock, samples) {
72
+ let outcome;
73
+ try {
74
+ outcome = await send(offset);
75
+ } catch (error) {
76
+ outcome = {
77
+ ok: false,
78
+ errorKind: "exception",
79
+ reason: String(error)
80
+ };
81
+ }
82
+ samples.push({
83
+ scheduledAtMs: offset,
84
+ latencyMs: clock.now() - (start + offset),
85
+ warmup: offset < warmupMs,
86
+ outcome
87
+ });
88
+ }
89
+ function createSemaphore(max) {
90
+ if (max == null) return {
91
+ acquire: () => Promise.resolve(false),
92
+ release: () => {}
93
+ };
94
+ let count = 0;
95
+ const queue = [];
96
+ return {
97
+ acquire() {
98
+ if (count < max) {
99
+ count++;
100
+ return Promise.resolve(false);
101
+ }
102
+ return new Promise((resolve) => queue.push(() => resolve(true)));
103
+ },
104
+ release() {
105
+ const next = queue.shift();
106
+ if (next != null) next();
107
+ else count--;
108
+ }
109
+ };
110
+ }
111
+ //#endregion
112
+ export { runLoad };