@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,155 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { discoverInbox, selectInbox } from "../discovery/discover.js";
|
|
3
|
+
import { asList } from "../scenario/coerce.js";
|
|
4
|
+
import { runLoad } from "../load/generator.js";
|
|
5
|
+
import { aggregateSamples } from "../metrics/aggregate.js";
|
|
6
|
+
import { diffSnapshots, fetchServerSnapshot, snapshotToMetrics } from "../metrics/stats-client.js";
|
|
7
|
+
import { createActivityIdMinter } from "../signing/activity-id.js";
|
|
8
|
+
import { createSigningPipeline } from "../signing/pipeline.js";
|
|
9
|
+
import { signInboxDelivery } from "../signing/signer.js";
|
|
10
|
+
import { isGenerateDirective, resolveGenerate } from "../template/generate.js";
|
|
11
|
+
import { estimateTotal, loadPlanOf, measuredWindowMs, sendRequest, withMeasuredWindowStart } from "./runner.js";
|
|
12
|
+
import { Create, Note } from "@fedify/vocab";
|
|
13
|
+
//#region src/bench/scenarios/inbox.ts
|
|
14
|
+
/**
|
|
15
|
+
* The `inbox` scenario runner: the end-to-end signed-delivery benchmark.
|
|
16
|
+
*
|
|
17
|
+
* It discovers the recipient's inbox the way a real peer does, then drives
|
|
18
|
+
* signed activity deliveries through the signing pipeline, aggregates the
|
|
19
|
+
* client-measured results, and reads the target's server-side metrics.
|
|
20
|
+
* @since 2.3.0
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
/** The `inbox` scenario runner. */
|
|
24
|
+
const inboxRunner = {
|
|
25
|
+
validate(scenario) {
|
|
26
|
+
validateActivity(scenario);
|
|
27
|
+
validateInbox(scenario);
|
|
28
|
+
},
|
|
29
|
+
async run(context) {
|
|
30
|
+
const { scenario, fleet } = context;
|
|
31
|
+
if (fleet == null || fleet.actors.length < 1) throw new Error("The inbox scenario requires the synthetic actor server.");
|
|
32
|
+
if (scenario.recipients.length < 1) throw new Error("The inbox scenario requires a recipient.");
|
|
33
|
+
validateActivity(scenario);
|
|
34
|
+
validateInbox(scenario);
|
|
35
|
+
const fetchImpl = context.fetch ?? fetch;
|
|
36
|
+
const targets = [];
|
|
37
|
+
for (const recipient of scenario.recipients) {
|
|
38
|
+
const discovered = await discoverInbox(recipient, {
|
|
39
|
+
documentLoader: context.documentLoader,
|
|
40
|
+
contextLoader: context.contextLoader,
|
|
41
|
+
allowPrivateAddress: context.allowPrivateAddress
|
|
42
|
+
});
|
|
43
|
+
const inbox = selectInbox(discovered, scenario.inbox);
|
|
44
|
+
await context.assertDestinationAllowed?.(inbox);
|
|
45
|
+
targets.push({
|
|
46
|
+
inbox,
|
|
47
|
+
actorUri: discovered.actorUri
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const actors = fleet.actors;
|
|
51
|
+
const minter = createActivityIdMinter(fleet.url);
|
|
52
|
+
let index = 0;
|
|
53
|
+
const factory = () => {
|
|
54
|
+
const i = index++;
|
|
55
|
+
const actor = actors[i % actors.length];
|
|
56
|
+
const target = targets[i % targets.length];
|
|
57
|
+
const activity = buildActivity(scenario.activity, actor, minter.next(), fleet.url, target.actorUri);
|
|
58
|
+
return signInboxDelivery({
|
|
59
|
+
actor,
|
|
60
|
+
inbox: target.inbox,
|
|
61
|
+
activity,
|
|
62
|
+
contextLoader: context.contextLoader
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const pipeline = createSigningPipeline(scenario.signing, factory, { total: estimateTotal(scenario) });
|
|
66
|
+
const rawSend = async () => {
|
|
67
|
+
let request;
|
|
68
|
+
try {
|
|
69
|
+
request = await pipeline.next();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
errorKind: "client",
|
|
74
|
+
reason: String(error)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return sendRequest(request, fetchImpl);
|
|
78
|
+
};
|
|
79
|
+
let baseline = null;
|
|
80
|
+
let baselineTaken = false;
|
|
81
|
+
const send = withMeasuredWindowStart(scenario.warmupMs, async () => {
|
|
82
|
+
baseline = await fetchServerSnapshot(context.target, fetchImpl);
|
|
83
|
+
baselineTaken = true;
|
|
84
|
+
}, rawSend);
|
|
85
|
+
try {
|
|
86
|
+
await pipeline.prime();
|
|
87
|
+
const measurement = aggregateSamples((await runLoad(loadPlanOf(scenario, context.rng), send, context.clock)).samples, {
|
|
88
|
+
measuredWindowMs: measuredWindowMs(scenario),
|
|
89
|
+
includeHistogram: true
|
|
90
|
+
});
|
|
91
|
+
const end = await fetchServerSnapshot(context.target, fetchImpl);
|
|
92
|
+
const server = baselineTaken && baseline != null && end != null ? snapshotToMetrics(diffSnapshots(baseline, end)) : null;
|
|
93
|
+
return {
|
|
94
|
+
...measurement,
|
|
95
|
+
server
|
|
96
|
+
};
|
|
97
|
+
} finally {
|
|
98
|
+
await pipeline.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Validates the scenario's `inbox` mode. `"shared"` and `"personal"` select a
|
|
104
|
+
* discovered inbox; any other value is an explicit inbox URL the run will POST
|
|
105
|
+
* to, so it must be a usable bare http(s) URL. Without this preflight check, a
|
|
106
|
+
* typo like `inbox: shraed` would crash `selectInbox` with an uncaught error
|
|
107
|
+
* mid-run, and a non-http URL would slip through to the send path.
|
|
108
|
+
*/
|
|
109
|
+
function validateInbox(scenario) {
|
|
110
|
+
const mode = scenario.inbox;
|
|
111
|
+
if (mode == null || mode === "shared" || mode === "personal") return;
|
|
112
|
+
let url;
|
|
113
|
+
try {
|
|
114
|
+
url = new URL(mode);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`Scenario "${scenario.name}": inbox must be "shared", "personal", or an http(s) URL; got ${JSON.stringify(mode)}.`);
|
|
117
|
+
}
|
|
118
|
+
if (url.protocol !== "http:" && url.protocol !== "https:" || url.hostname === "" || url.username !== "" || url.password !== "") throw new Error(`Scenario "${scenario.name}": inbox URL must be a bare http(s) URL with a host and no credentials; got ${JSON.stringify(mode)}.`);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Rejects the activity options the inbox runner cannot yet honor: it always
|
|
122
|
+
* delivers a `Create` carrying an embedded `Note`, so a different activity or
|
|
123
|
+
* object type, or `embedObject: false`, is refused with a clear message.
|
|
124
|
+
*/
|
|
125
|
+
function validateActivity(scenario) {
|
|
126
|
+
const spec = scenario.activity;
|
|
127
|
+
if (spec == null) return;
|
|
128
|
+
const badType = asList(spec.type).find((type) => type !== "Create");
|
|
129
|
+
if (badType != null) throw new Error(`Scenario "${scenario.name}": the inbox runner currently supports only Create activities; got ${JSON.stringify(badType)}.`);
|
|
130
|
+
if (spec.embedObject === false) throw new Error(`Scenario "${scenario.name}": the inbox runner always embeds the activity's object; embedObject: false is not yet supported.`);
|
|
131
|
+
const badObjectType = asList(spec.object?.type).find((type) => type !== "Note");
|
|
132
|
+
if (badObjectType != null) throw new Error(`Scenario "${scenario.name}": the inbox runner currently supports only Note objects; got ${JSON.stringify(badObjectType)}.`);
|
|
133
|
+
}
|
|
134
|
+
function buildActivity(spec, actor, id, base, recipient) {
|
|
135
|
+
const note = new Note({
|
|
136
|
+
id: new URL(`/objects/${crypto.randomUUID()}`, base),
|
|
137
|
+
attribution: actor.id,
|
|
138
|
+
content: resolveContent(spec?.object?.content),
|
|
139
|
+
to: recipient
|
|
140
|
+
});
|
|
141
|
+
return new Create({
|
|
142
|
+
id,
|
|
143
|
+
actor: actor.id,
|
|
144
|
+
object: note,
|
|
145
|
+
to: recipient
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function resolveContent(content) {
|
|
149
|
+
if (content == null) return "Benchmark activity.";
|
|
150
|
+
if (typeof content === "string") return content;
|
|
151
|
+
if (isGenerateDirective(content)) return resolveGenerate(content);
|
|
152
|
+
return String(content);
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
export { inboxRunner };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { inboxRunner } from "./inbox.js";
|
|
3
|
+
import { webfingerRunner } from "./webfinger.js";
|
|
4
|
+
//#region src/bench/scenarios/registry.ts
|
|
5
|
+
/** The scenario types that have runners in this version. */
|
|
6
|
+
const IMPLEMENTED_SCENARIO_TYPES = ["inbox", "webfinger"];
|
|
7
|
+
/**
|
|
8
|
+
* Returns the runner for a scenario type.
|
|
9
|
+
* @param type The scenario type.
|
|
10
|
+
* @returns The runner.
|
|
11
|
+
* @throws {Error} If the type has no runner in this version.
|
|
12
|
+
*/
|
|
13
|
+
function runnerFor(type) {
|
|
14
|
+
switch (type) {
|
|
15
|
+
case "inbox": return inboxRunner;
|
|
16
|
+
case "webfinger": return webfingerRunner;
|
|
17
|
+
default: throw new Error(`The "${type}" scenario type is not implemented in this version of fedify bench; supported types: ${IMPLEMENTED_SCENARIO_TYPES.join(", ")}.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { runnerFor };
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
/**
|
|
50
|
+
* Wraps a send function so that `onMeasuredWindowStart` runs exactly once, at
|
|
51
|
+
* the warm-up boundary, and *every* measured request waits for it to settle
|
|
52
|
+
* before being sent. Runners use this to snapshot a server-side baseline so
|
|
53
|
+
* reported server metrics cover only the measured window rather than the
|
|
54
|
+
* target's cumulative lifetime; awaiting it on every measured send guarantees
|
|
55
|
+
* the baseline is taken before any measured traffic reaches the target, so no
|
|
56
|
+
* measured request can leak into the baseline.
|
|
57
|
+
*
|
|
58
|
+
* The barrier is cheap: only the handful of requests scheduled while the
|
|
59
|
+
* baseline snapshot is in flight wait for it (recording that wait as their own
|
|
60
|
+
* latency, the coordinated-omission-correct outcome); once it settles, later
|
|
61
|
+
* waits resolve immediately.
|
|
62
|
+
* @param warmupMs The warm-up window length, in milliseconds.
|
|
63
|
+
* @param onMeasuredWindowStart The one-shot callback, run at the boundary.
|
|
64
|
+
* @param send The underlying send function.
|
|
65
|
+
* @returns A send function that gates measured sends on the callback.
|
|
66
|
+
*/
|
|
67
|
+
function withMeasuredWindowStart(warmupMs, onMeasuredWindowStart, send) {
|
|
68
|
+
let started;
|
|
69
|
+
return (scheduledAtMs) => {
|
|
70
|
+
if (scheduledAtMs < warmupMs) return send(scheduledAtMs);
|
|
71
|
+
started ??= Promise.resolve().then(onMeasuredWindowStart);
|
|
72
|
+
return started.then(() => send(scheduledAtMs));
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { estimateTotal, loadPlanOf, measuredWindowMs, sendRequest, 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)).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 };
|