@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,108 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { runLoad } from "../load/generator.js";
|
|
3
|
+
import { aggregateSamples } from "../metrics/aggregate.js";
|
|
4
|
+
import { diffSnapshots, fetchServerSnapshot, snapshotToMetrics } from "../metrics/stats-client.js";
|
|
5
|
+
import { createSigningPipeline } from "../signing/pipeline.js";
|
|
6
|
+
import { assertBareHttpUrl, estimateTotal, loadPlanOf, measuredWindowMs, sendRequest, withMeasuredWindowStart } from "./runner.js";
|
|
7
|
+
import { signRequest } from "@fedify/fedify";
|
|
8
|
+
//#region src/bench/scenarios/read.ts
|
|
9
|
+
/**
|
|
10
|
+
* Shared helpers for read-only benchmark scenarios.
|
|
11
|
+
* @since 2.3.0
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
const READ_GATE_CONCURRENCY = 16;
|
|
15
|
+
/**
|
|
16
|
+
* Runs a read-only GET workload and aggregates client/server measurements.
|
|
17
|
+
* @param context The scenario run context.
|
|
18
|
+
* @param options Read workload options.
|
|
19
|
+
* @returns The scenario measurement.
|
|
20
|
+
*/
|
|
21
|
+
async function runReadLoad(context, options) {
|
|
22
|
+
if (options.urls.length < 1) throw new Error(`Scenario "${context.scenario.name}" did not resolve any URLs to fetch.`);
|
|
23
|
+
const fetchImpl = context.fetch ?? fetch;
|
|
24
|
+
const actors = context.fleet?.actors ?? [];
|
|
25
|
+
if (options.authenticated && actors.length < 1) throw new Error(`Scenario "${context.scenario.name}" requires the synthetic actor server for authenticated fetches.`);
|
|
26
|
+
for (const url of options.urls) assertBareHttpUrl(context.scenario.name, "read URL", url);
|
|
27
|
+
await mapWithConcurrency(options.urls, READ_GATE_CONCURRENCY, async (url) => {
|
|
28
|
+
if (options.authenticated) await context.assertDestinationAllowed?.(url);
|
|
29
|
+
else await context.assertReadDestinationAllowed?.(url);
|
|
30
|
+
});
|
|
31
|
+
function unsignedRequest(index) {
|
|
32
|
+
const url = options.urls[index % options.urls.length];
|
|
33
|
+
return new Request(url, {
|
|
34
|
+
headers: { accept: "application/activity+json, application/ld+json" },
|
|
35
|
+
redirect: "manual"
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
let pipeline = null;
|
|
39
|
+
if (options.authenticated) {
|
|
40
|
+
let signIndex = 0;
|
|
41
|
+
pipeline = createSigningPipeline(context.scenario.signing, async () => {
|
|
42
|
+
const i = signIndex++;
|
|
43
|
+
return await signGetRequest(unsignedRequest(i), actors[i % actors.length]);
|
|
44
|
+
}, { total: estimateTotal(context.scenario) });
|
|
45
|
+
}
|
|
46
|
+
let index = 0;
|
|
47
|
+
const rawSend = async () => {
|
|
48
|
+
let request;
|
|
49
|
+
if (pipeline != null) try {
|
|
50
|
+
request = await pipeline.next();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
errorKind: "client",
|
|
55
|
+
reason: String(error)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
else request = unsignedRequest(index++);
|
|
59
|
+
return await sendRequest(request, fetchImpl);
|
|
60
|
+
};
|
|
61
|
+
let baseline = null;
|
|
62
|
+
let baselineTaken = false;
|
|
63
|
+
const send = withMeasuredWindowStart(context.scenario.warmupMs, async () => {
|
|
64
|
+
baseline = await fetchServerSnapshot(context.target, fetchImpl);
|
|
65
|
+
baselineTaken = true;
|
|
66
|
+
}, rawSend);
|
|
67
|
+
try {
|
|
68
|
+
await pipeline?.prime();
|
|
69
|
+
const measurement = aggregateSamples((await runLoad(loadPlanOf(context.scenario, context.rng), send, context.clock, context.signal)).samples, {
|
|
70
|
+
measuredWindowMs: measuredWindowMs(context.scenario),
|
|
71
|
+
includeHistogram: true
|
|
72
|
+
});
|
|
73
|
+
const end = await fetchServerSnapshot(context.target, fetchImpl);
|
|
74
|
+
const server = baselineTaken && baseline != null && end != null ? snapshotToMetrics(diffSnapshots(baseline, end)) : null;
|
|
75
|
+
return {
|
|
76
|
+
...measurement,
|
|
77
|
+
server
|
|
78
|
+
};
|
|
79
|
+
} finally {
|
|
80
|
+
await pipeline?.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function mapWithConcurrency(items, concurrency, callback) {
|
|
84
|
+
let next = 0;
|
|
85
|
+
let firstError;
|
|
86
|
+
let hasError = false;
|
|
87
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
88
|
+
while (next < items.length && !hasError) {
|
|
89
|
+
const item = items[next++];
|
|
90
|
+
try {
|
|
91
|
+
await callback(item);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!hasError) {
|
|
94
|
+
hasError = true;
|
|
95
|
+
firstError = error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
await Promise.all(workers);
|
|
101
|
+
if (hasError) throw firstError;
|
|
102
|
+
}
|
|
103
|
+
async function signGetRequest(request, actor) {
|
|
104
|
+
if (actor.keys?.rsa == null || actor.rsaKeyId == null) throw new TypeError("Actor is missing the RSA key required for authenticated fetch signing.");
|
|
105
|
+
return await signRequest(request, actor.keys.rsa.privateKey, actor.rsaKeyId, { spec: actor.httpStandard });
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { runReadLoad };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { actorRunner } from "./actor.js";
|
|
3
|
+
import { fanoutRunner } from "./fanout.js";
|
|
4
|
+
import { failureRunner } from "./failure.js";
|
|
5
|
+
import { inboxRunner } from "./inbox.js";
|
|
6
|
+
import { objectRunner } from "./object.js";
|
|
7
|
+
import { webfingerRunner } from "./webfinger.js";
|
|
8
|
+
import { mixedRunner } from "./mixed.js";
|
|
9
|
+
//#region src/bench/scenarios/registry.ts
|
|
10
|
+
/** The scenario types that have runners in this version. */
|
|
11
|
+
const IMPLEMENTED_SCENARIO_TYPES = [
|
|
12
|
+
"inbox",
|
|
13
|
+
"webfinger",
|
|
14
|
+
"actor",
|
|
15
|
+
"object",
|
|
16
|
+
"fanout",
|
|
17
|
+
"failure",
|
|
18
|
+
"mixed"
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Returns the runner for a scenario type.
|
|
22
|
+
* @param type The scenario type.
|
|
23
|
+
* @returns The runner.
|
|
24
|
+
* @throws {Error} If the type has no runner in this version.
|
|
25
|
+
*/
|
|
26
|
+
function runnerFor(type) {
|
|
27
|
+
switch (type) {
|
|
28
|
+
case "inbox": return inboxRunner;
|
|
29
|
+
case "webfinger": return webfingerRunner;
|
|
30
|
+
case "actor": return actorRunner;
|
|
31
|
+
case "object": return objectRunner;
|
|
32
|
+
case "fanout": return fanoutRunner;
|
|
33
|
+
case "failure": return failureRunner;
|
|
34
|
+
case "mixed": return mixedRunner;
|
|
35
|
+
default: throw new Error(`The "${type}" scenario type is not implemented in this version of fedify bench; supported types: ${IMPLEMENTED_SCENARIO_TYPES.join(", ")}.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { runnerFor };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/scenarios/runner.ts
|
|
3
|
+
/** Performs one HTTP send and classifies the result as a send outcome. */
|
|
4
|
+
async function sendRequest(request, fetchImpl) {
|
|
5
|
+
const noFollow = request.redirect === "manual" ? request : new Request(request, { redirect: "manual" });
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetchImpl(noFollow);
|
|
8
|
+
await response.arrayBuffer().catch(() => {});
|
|
9
|
+
if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) return {
|
|
10
|
+
ok: false,
|
|
11
|
+
status: response.status === 0 ? void 0 : response.status,
|
|
12
|
+
reason: "redirect"
|
|
13
|
+
};
|
|
14
|
+
if (response.ok) return {
|
|
15
|
+
ok: true,
|
|
16
|
+
status: response.status
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
status: response.status,
|
|
21
|
+
reason: `status_${response.status}`
|
|
22
|
+
};
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
errorKind: "network",
|
|
27
|
+
reason: String(error)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Builds the load plan for a resolved scenario. */
|
|
32
|
+
function loadPlanOf(scenario, rng) {
|
|
33
|
+
return {
|
|
34
|
+
load: scenario.load,
|
|
35
|
+
durationMs: scenario.durationMs,
|
|
36
|
+
warmupMs: scenario.warmupMs,
|
|
37
|
+
rng
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The measured window (excluding warm-up) used for throughput, in ms. */
|
|
41
|
+
function measuredWindowMs(scenario) {
|
|
42
|
+
return Math.max(scenario.durationMs - scenario.warmupMs, 1);
|
|
43
|
+
}
|
|
44
|
+
/** Estimates the total request count, for presigning open-loop runs. */
|
|
45
|
+
function estimateTotal(scenario) {
|
|
46
|
+
if (scenario.load.kind !== "open") return void 0;
|
|
47
|
+
return Math.ceil(scenario.load.ratePerSec * (scenario.durationMs / 1e3));
|
|
48
|
+
}
|
|
49
|
+
/** Returns whether a URL is fetchable by benchmark runners without surprises. */
|
|
50
|
+
function isBareHttpUrl(url) {
|
|
51
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "" && url.username === "" && url.password === "";
|
|
52
|
+
}
|
|
53
|
+
/** Rejects URLs that are not bare http(s) URLs with a host and no credentials. */
|
|
54
|
+
function assertBareHttpUrl(scenarioName, label, url) {
|
|
55
|
+
if (isBareHttpUrl(url)) return;
|
|
56
|
+
throw new Error(`Scenario "${scenarioName}": ${label} must be a bare http(s) URL with a host and no credentials; got ${JSON.stringify(url.href)}.`);
|
|
57
|
+
}
|
|
58
|
+
/** Validates an inbox selector or explicit inbox URL. */
|
|
59
|
+
function validateInboxSelector(scenarioName, inbox) {
|
|
60
|
+
if (inbox == null || inbox === "shared" || inbox === "personal") return;
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(inbox);
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(`Scenario "${scenarioName}": inbox must be "shared", "personal", or an http(s) URL; got ${JSON.stringify(inbox)}.`);
|
|
66
|
+
}
|
|
67
|
+
assertBareHttpUrl(scenarioName, "inbox URL", url);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Wraps a send function so that `onMeasuredWindowStart` runs exactly once, at
|
|
71
|
+
* the warm-up boundary, and *every* measured request waits for it to settle
|
|
72
|
+
* before being sent. Runners use this to snapshot a server-side baseline so
|
|
73
|
+
* reported server metrics cover only the measured window rather than the
|
|
74
|
+
* target's cumulative lifetime; awaiting it on every measured send guarantees
|
|
75
|
+
* the baseline is taken before any measured traffic reaches the target, so no
|
|
76
|
+
* measured request can leak into the baseline.
|
|
77
|
+
*
|
|
78
|
+
* The barrier is cheap: only the handful of requests scheduled while the
|
|
79
|
+
* baseline snapshot is in flight wait for it (recording that wait as their own
|
|
80
|
+
* latency, the coordinated-omission-correct outcome); once it settles, later
|
|
81
|
+
* waits resolve immediately.
|
|
82
|
+
* @param warmupMs The warm-up window length, in milliseconds.
|
|
83
|
+
* @param onMeasuredWindowStart The one-shot callback, run at the boundary.
|
|
84
|
+
* @param send The underlying send function.
|
|
85
|
+
* @returns A send function that gates measured sends on the callback.
|
|
86
|
+
*/
|
|
87
|
+
function withMeasuredWindowStart(warmupMs, onMeasuredWindowStart, send) {
|
|
88
|
+
let started;
|
|
89
|
+
return (scheduledAtMs) => {
|
|
90
|
+
if (scheduledAtMs < warmupMs) return send(scheduledAtMs);
|
|
91
|
+
started ??= Promise.resolve().then(onMeasuredWindowStart);
|
|
92
|
+
return started.then(() => send(scheduledAtMs));
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { assertBareHttpUrl, estimateTotal, isBareHttpUrl, loadPlanOf, measuredWindowMs, sendRequest, validateInboxSelector, withMeasuredWindowStart };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { convertUrlIfHandle } from "../../webfinger/lib.js";
|
|
3
|
+
import { runLoad } from "../load/generator.js";
|
|
4
|
+
import { aggregateSamples } from "../metrics/aggregate.js";
|
|
5
|
+
import { diffSnapshots, fetchServerSnapshot, snapshotToMetrics } from "../metrics/stats-client.js";
|
|
6
|
+
import { loadPlanOf, measuredWindowMs, sendRequest, withMeasuredWindowStart } from "./runner.js";
|
|
7
|
+
//#region src/bench/scenarios/webfinger.ts
|
|
8
|
+
/**
|
|
9
|
+
* The `webfinger` scenario runner: drives WebFinger handle-resolution lookups,
|
|
10
|
+
* the discovery primitive every other scenario reuses.
|
|
11
|
+
* @since 2.3.0
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
function webfingerUrl(target, recipient) {
|
|
15
|
+
const resource = convertUrlIfHandle(recipient).href;
|
|
16
|
+
const url = new URL("/.well-known/webfinger", target);
|
|
17
|
+
url.searchParams.set("resource", resource);
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
/** The `webfinger` scenario runner. */
|
|
21
|
+
const webfingerRunner = { async run(context) {
|
|
22
|
+
const fetchImpl = context.fetch ?? fetch;
|
|
23
|
+
const urls = (context.scenario.recipients.length > 0 ? context.scenario.recipients : [context.target.href]).map((r) => webfingerUrl(context.target, r));
|
|
24
|
+
let index = 0;
|
|
25
|
+
const rawSend = () => sendRequest(new Request(urls[index++ % urls.length], { redirect: "manual" }), fetchImpl);
|
|
26
|
+
let baseline = null;
|
|
27
|
+
let baselineTaken = false;
|
|
28
|
+
const send = withMeasuredWindowStart(context.scenario.warmupMs, async () => {
|
|
29
|
+
baseline = await fetchServerSnapshot(context.target, fetchImpl);
|
|
30
|
+
baselineTaken = true;
|
|
31
|
+
}, rawSend);
|
|
32
|
+
const measurement = aggregateSamples((await runLoad(loadPlanOf(context.scenario, context.rng), send, context.clock, context.signal)).samples, {
|
|
33
|
+
measuredWindowMs: measuredWindowMs(context.scenario),
|
|
34
|
+
includeHistogram: true
|
|
35
|
+
});
|
|
36
|
+
const end = await fetchServerSnapshot(context.target, fetchImpl);
|
|
37
|
+
const server = baselineTaken && baseline != null && end != null ? snapshotToMetrics(diffSnapshots(baseline, end)) : null;
|
|
38
|
+
return {
|
|
39
|
+
...measurement,
|
|
40
|
+
server
|
|
41
|
+
};
|
|
42
|
+
} };
|
|
43
|
+
//#endregion
|
|
44
|
+
export { webfingerRunner };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { getContextLoader } from "../../docloader.js";
|
|
3
|
+
import { actorDocument } from "../actor/documents.js";
|
|
4
|
+
import { serve } from "srvx";
|
|
5
|
+
//#region src/bench/server/synthetic.ts
|
|
6
|
+
/**
|
|
7
|
+
* The benchmark's own synthetic actor/key server.
|
|
8
|
+
*
|
|
9
|
+
* It serves the actor documents (with embedded keys) that the target
|
|
10
|
+
* dereferences while verifying signatures, over plain HTTP — which works
|
|
11
|
+
* because `benchmarkMode` enables `allowPrivateAddress` on the target. By
|
|
12
|
+
* default it binds loopback and advertises a `127.0.0.1` base URL, which a
|
|
13
|
+
* same-machine (loopback) target can reach. For a non-loopback target, pass
|
|
14
|
+
* `advertiseHost`: the server then binds every interface and advertises that
|
|
15
|
+
* host in the actor/key URLs, so the remote target can dereference them.
|
|
16
|
+
* @since 2.3.0
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Starts the synthetic actor/key server and serves each fleet member's actor
|
|
21
|
+
* document.
|
|
22
|
+
* @param members The fleet members (with keys) to serve.
|
|
23
|
+
* @param options Server options.
|
|
24
|
+
* @returns The running server, including the actors with their assigned URLs.
|
|
25
|
+
*/
|
|
26
|
+
async function spawnSyntheticServer(members, options = {}) {
|
|
27
|
+
const advertised = options.advertiseHost == null ? null : resolveAdvertiseHost(options.advertiseHost);
|
|
28
|
+
const routes = /* @__PURE__ */ new Map();
|
|
29
|
+
const server = serve({
|
|
30
|
+
port: 0,
|
|
31
|
+
hostname: advertised?.bindHost ?? "127.0.0.1",
|
|
32
|
+
silent: true,
|
|
33
|
+
fetch(request) {
|
|
34
|
+
const { pathname } = new URL(request.url);
|
|
35
|
+
const body = routes.get(pathname);
|
|
36
|
+
if (body == null) return new Response("Not found", { status: 404 });
|
|
37
|
+
return new Response(body, {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "content-type": "application/activity+json" }
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
await server.ready();
|
|
44
|
+
const actors = [];
|
|
45
|
+
try {
|
|
46
|
+
const bound = new URL(server.url);
|
|
47
|
+
const base = advertised == null ? bound : new URL(`http://${advertised.urlHost}:${bound.port}/`);
|
|
48
|
+
const contextLoader = options.contextLoader ?? await getContextLoader({ allowPrivateAddress: true });
|
|
49
|
+
for (const member of members) {
|
|
50
|
+
const id = new URL(`/actors/${member.index}`, base);
|
|
51
|
+
const actor = {
|
|
52
|
+
...member,
|
|
53
|
+
id,
|
|
54
|
+
rsaKeyId: member.keys.rsa == null ? void 0 : new URL("#main-key", id),
|
|
55
|
+
ed25519KeyId: member.keys.ed25519 == null ? void 0 : new URL("#ed25519-key", id)
|
|
56
|
+
};
|
|
57
|
+
const document = await actorDocument(actor, { contextLoader });
|
|
58
|
+
routes.set(`/actors/${member.index}`, JSON.stringify(document));
|
|
59
|
+
actors.push(actor);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
url: base,
|
|
63
|
+
actors,
|
|
64
|
+
async close() {
|
|
65
|
+
await server.close(true);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await server.close(true);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** An error raised when `--advertise-host` is not a usable bare host. */
|
|
74
|
+
var AdvertiseHostError = class extends Error {};
|
|
75
|
+
/**
|
|
76
|
+
* Validates and normalizes an `--advertise-host` value into a bind address and a
|
|
77
|
+
* URL-authority host. It must be a bare host name, IPv4 address, or IPv6
|
|
78
|
+
* literal (bracketed or not); a scheme, port, path, or other URL syntax is
|
|
79
|
+
* rejected, since the synthetic server's chosen port is appended automatically.
|
|
80
|
+
* An IPv6 host binds every IPv6 interface (`::`); anything else binds every IPv4
|
|
81
|
+
* interface (`0.0.0.0`).
|
|
82
|
+
* @param host The raw `--advertise-host` value.
|
|
83
|
+
* @returns The bind address and the URL-authority host.
|
|
84
|
+
* @throws {AdvertiseHostError} If the value is not a usable bare host.
|
|
85
|
+
*/
|
|
86
|
+
function resolveAdvertiseHost(host) {
|
|
87
|
+
const trimmed = host.trim();
|
|
88
|
+
if (trimmed === "") throw new AdvertiseHostError("--advertise-host must not be empty.");
|
|
89
|
+
if (/[\s/\\@?#]/.test(trimmed) || trimmed.includes("://")) throw new AdvertiseHostError(`Invalid --advertise-host ${JSON.stringify(host)}: give a bare host name or IP address, with no scheme, path, or whitespace.`);
|
|
90
|
+
let urlHost;
|
|
91
|
+
let bindHost;
|
|
92
|
+
if (trimmed.startsWith("[")) {
|
|
93
|
+
if (!trimmed.endsWith("]")) throw new AdvertiseHostError(`Invalid --advertise-host ${JSON.stringify(host)}: unbalanced brackets around the IPv6 address.`);
|
|
94
|
+
urlHost = trimmed;
|
|
95
|
+
bindHost = "::";
|
|
96
|
+
} else {
|
|
97
|
+
const colons = (trimmed.match(/:/g) ?? []).length;
|
|
98
|
+
if (colons === 1) throw new AdvertiseHostError(`Invalid --advertise-host ${JSON.stringify(host)}: omit the port; the synthetic server's chosen port is appended automatically.`);
|
|
99
|
+
if (colons >= 2) {
|
|
100
|
+
urlHost = `[${trimmed}]`;
|
|
101
|
+
bindHost = "::";
|
|
102
|
+
} else {
|
|
103
|
+
urlHost = trimmed;
|
|
104
|
+
bindHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed) ? "0.0.0.0" : "::";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
new URL(`http://${urlHost}/`);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new AdvertiseHostError(`Invalid --advertise-host ${JSON.stringify(host)}: not a valid host name or IP address.`);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
bindHost,
|
|
114
|
+
urlHost
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
export { resolveAdvertiseHost, spawnSyntheticServer };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/signing/activity-id.ts
|
|
3
|
+
/**
|
|
4
|
+
* Creates a minter that produces unique activity ids under a base URL. Ids
|
|
5
|
+
* combine a per-run random component with a monotonic counter, so they are
|
|
6
|
+
* unique within a run and across runs.
|
|
7
|
+
* @param base The base URL (typically the synthetic server's URL).
|
|
8
|
+
* @returns A new minter.
|
|
9
|
+
*/
|
|
10
|
+
function createActivityIdMinter(base) {
|
|
11
|
+
const run = crypto.randomUUID();
|
|
12
|
+
let counter = 0;
|
|
13
|
+
return { next() {
|
|
14
|
+
return new URL(`/activities/${run}/${counter++}`, base);
|
|
15
|
+
} };
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { createActivityIdMinter };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/signing/pipeline.ts
|
|
3
|
+
/** An error used to release consumers waiting on a closed pipeline. */
|
|
4
|
+
var PipelineClosedError = class extends Error {};
|
|
5
|
+
const DEFAULT_BUFFER_SIZE = 256;
|
|
6
|
+
const DEFAULT_SIGNERS = 4;
|
|
7
|
+
/**
|
|
8
|
+
* After this many signing failures with no successful sign in between, the
|
|
9
|
+
* pipeline gives up so a deterministic signing error fails fast instead of
|
|
10
|
+
* spinning forever.
|
|
11
|
+
*/
|
|
12
|
+
const FATAL_FAILURE_THRESHOLD = 8;
|
|
13
|
+
/**
|
|
14
|
+
* Creates a signing pipeline for the given mode.
|
|
15
|
+
* @param mode The lookahead mode.
|
|
16
|
+
* @param factory The per-request signing factory.
|
|
17
|
+
* @param options Buffer, total, and concurrency options.
|
|
18
|
+
* @returns The signing pipeline.
|
|
19
|
+
*/
|
|
20
|
+
function createSigningPipeline(mode, factory, options = {}) {
|
|
21
|
+
if (mode === "jit") return createJit(factory);
|
|
22
|
+
const signers = options.signers ?? DEFAULT_SIGNERS;
|
|
23
|
+
if (mode === "presign") {
|
|
24
|
+
const total = options.total ?? DEFAULT_BUFFER_SIZE;
|
|
25
|
+
return createBuffered(factory, {
|
|
26
|
+
bufferSize: total,
|
|
27
|
+
fillTarget: total,
|
|
28
|
+
signers,
|
|
29
|
+
countStarvation: false,
|
|
30
|
+
maxProduced: total
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
|
|
34
|
+
return createBuffered(factory, {
|
|
35
|
+
bufferSize,
|
|
36
|
+
fillTarget: bufferSize,
|
|
37
|
+
signers,
|
|
38
|
+
countStarvation: true
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function createJit(factory) {
|
|
42
|
+
return {
|
|
43
|
+
next: factory,
|
|
44
|
+
prime: () => Promise.resolve(),
|
|
45
|
+
starvationCount: 0,
|
|
46
|
+
close: () => Promise.resolve()
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function createBuffered(factory, options) {
|
|
50
|
+
const ready = [];
|
|
51
|
+
const waiters = [];
|
|
52
|
+
const maxProduced = options.maxProduced ?? Infinity;
|
|
53
|
+
let produced = 0;
|
|
54
|
+
let starvationCount = 0;
|
|
55
|
+
let inFlight = 0;
|
|
56
|
+
let closed = false;
|
|
57
|
+
let consecutiveFailures = 0;
|
|
58
|
+
let fatalError = null;
|
|
59
|
+
const CLOSED = Symbol("closed");
|
|
60
|
+
let signalClose;
|
|
61
|
+
const closeSignal = new Promise((resolve) => {
|
|
62
|
+
signalClose = () => resolve(CLOSED);
|
|
63
|
+
});
|
|
64
|
+
function deliver(request) {
|
|
65
|
+
const waiter = waiters.shift();
|
|
66
|
+
if (waiter != null) waiter.resolve(request);
|
|
67
|
+
else ready.push(request);
|
|
68
|
+
}
|
|
69
|
+
function fail(error) {
|
|
70
|
+
fatalError = error;
|
|
71
|
+
closed = true;
|
|
72
|
+
signalClose();
|
|
73
|
+
ready.length = 0;
|
|
74
|
+
while (waiters.length > 0) waiters.shift().reject(error);
|
|
75
|
+
}
|
|
76
|
+
async function producer() {
|
|
77
|
+
while (!closed) {
|
|
78
|
+
if (produced + inFlight >= maxProduced) break;
|
|
79
|
+
if (waiters.length === 0 && ready.length + inFlight >= options.bufferSize) {
|
|
80
|
+
await Promise.race([delay(), closeSignal]);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
inFlight++;
|
|
84
|
+
try {
|
|
85
|
+
const pending = Promise.resolve().then(factory);
|
|
86
|
+
pending.catch(() => {});
|
|
87
|
+
const result = await Promise.race([pending, closeSignal]);
|
|
88
|
+
if (result === CLOSED || closed) break;
|
|
89
|
+
consecutiveFailures = 0;
|
|
90
|
+
produced++;
|
|
91
|
+
deliver(result);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (++consecutiveFailures >= FATAL_FAILURE_THRESHOLD) fail(error);
|
|
94
|
+
} finally {
|
|
95
|
+
inFlight--;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const producers = Array.from({ length: options.signers }, () => producer());
|
|
100
|
+
return {
|
|
101
|
+
get starvationCount() {
|
|
102
|
+
return starvationCount;
|
|
103
|
+
},
|
|
104
|
+
next() {
|
|
105
|
+
const buffered = ready.shift();
|
|
106
|
+
if (buffered != null) return Promise.resolve(buffered);
|
|
107
|
+
if (fatalError != null) return Promise.reject(fatalError);
|
|
108
|
+
if (closed) return Promise.reject(new PipelineClosedError("closed"));
|
|
109
|
+
if (produced >= maxProduced) return Promise.resolve().then(factory);
|
|
110
|
+
if (options.countStarvation) starvationCount++;
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
waiters.push({
|
|
113
|
+
resolve,
|
|
114
|
+
reject
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
async prime() {
|
|
119
|
+
while (!closed && ready.length < options.fillTarget) await Promise.race([delay(), closeSignal]);
|
|
120
|
+
if (fatalError != null) throw fatalError;
|
|
121
|
+
},
|
|
122
|
+
async close() {
|
|
123
|
+
closed = true;
|
|
124
|
+
signalClose();
|
|
125
|
+
while (waiters.length > 0) waiters.shift().reject(new PipelineClosedError("closed"));
|
|
126
|
+
await Promise.allSettled(producers);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function delay() {
|
|
131
|
+
return new Promise((resolve) => setTimeout(resolve, 1));
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
export { createSigningPipeline };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { signJsonLd, signObject, signRequest } from "@fedify/fedify";
|
|
3
|
+
//#region src/bench/signing/signer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Signing one inbox delivery, reusing the `@fedify/fedify` signers so the
|
|
6
|
+
* client pays realistic crypto cost.
|
|
7
|
+
*
|
|
8
|
+
* Document signatures are applied first (FEP-8b32 object proof, then LD
|
|
9
|
+
* Signature on the serialized document), then the HTTP request signature is
|
|
10
|
+
* applied to the final body, matching how a real sender composes a request.
|
|
11
|
+
* @since 2.3.0
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Signs an inbox delivery and returns a ready-to-send `Request`.
|
|
16
|
+
* @param options The delivery options.
|
|
17
|
+
* @returns The signed POST request.
|
|
18
|
+
* @throws {TypeError} If the actor lacks the RSA key required for HTTP signing.
|
|
19
|
+
*/
|
|
20
|
+
async function signInboxDelivery(options) {
|
|
21
|
+
const { actor, inbox, contextLoader } = options;
|
|
22
|
+
if (actor.keys.rsa == null || actor.rsaKeyId == null) throw new TypeError("Actor is missing the RSA key required for HTTP request signing.");
|
|
23
|
+
let activity = options.activity;
|
|
24
|
+
if (actor.standards.includes("fep8b32") && actor.keys.ed25519 != null && actor.ed25519KeyId != null) activity = await signObject(activity, actor.keys.ed25519.privateKey, actor.ed25519KeyId, { contextLoader });
|
|
25
|
+
let document = await activity.toJsonLd({ contextLoader });
|
|
26
|
+
if (actor.standards.includes("ld-signatures")) document = await signJsonLd(document, actor.keys.rsa.privateKey, actor.rsaKeyId, { contextLoader });
|
|
27
|
+
const body = new TextEncoder().encode(JSON.stringify(document));
|
|
28
|
+
return await signRequest(new Request(inbox, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/activity+json" },
|
|
31
|
+
body,
|
|
32
|
+
redirect: "manual"
|
|
33
|
+
}), actor.keys.rsa.privateKey, actor.rsaKeyId, {
|
|
34
|
+
spec: actor.httpStandard,
|
|
35
|
+
body: body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { signInboxDelivery };
|