@crewhaus/federation-protocol 0.1.1 → 0.1.2
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/package.json +6 -11
- package/src/index.transport.test.ts +383 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/federation-protocol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cross-deployment A2A wire protocol: federation envelope (extends @crewhaus/a2a-protocol) + mTLS HTTPS POST transport with cert pinning (Section 34)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
/**
|
|
3
|
+
* Coverage for the production `defaultTransport` path — the branch that issues
|
|
4
|
+
* a real `node:https` POST with mTLS + cert pinning (src/index.ts lines
|
|
5
|
+
* 212-272). `federationCall` only reaches `defaultTransport` when no
|
|
6
|
+
* `transport` override is supplied.
|
|
7
|
+
*
|
|
8
|
+
* To stay fully deterministic — no real socket, no TLS handshake, no wall-clock
|
|
9
|
+
* timers — we replace `node:https.request` with a hand-driven fake via
|
|
10
|
+
* `mock.module`. The fake captures the assembled `RequestOptions` (so we can
|
|
11
|
+
* call `checkServerIdentity` directly) and hands back a `FakeClientRequest`
|
|
12
|
+
* (an `EventEmitter`) so the test drives the `error` / `timeout` lifecycle, and
|
|
13
|
+
* a `FakeResponse` (also an `EventEmitter`) so the test drives `data` / `end`.
|
|
14
|
+
*
|
|
15
|
+
* `mock.module` mutates the shared module registry, so the module under test is
|
|
16
|
+
* imported with `await import("./index")` AFTER the stub is registered (its
|
|
17
|
+
* `import { request as httpsRequest } from "node:https"` binding then resolves
|
|
18
|
+
* to the fake). `afterEach` restores the real `node:https` and clears per-test
|
|
19
|
+
* state so nothing leaks between tests or into sibling test files.
|
|
20
|
+
*
|
|
21
|
+
* NO-HANG CONTRACT — every `federationCall(...)` promise in this file is made
|
|
22
|
+
* to settle before the test ends:
|
|
23
|
+
* - happy / end paths resolve naturally once `res` emits `end`;
|
|
24
|
+
* - paths that only inspect `checkServerIdentity` settle by emitting a
|
|
25
|
+
* request `"error"` and asserting the call REJECTS;
|
|
26
|
+
* - the error / timeout paths assert `.rejects`;
|
|
27
|
+
* - the response-stream-error path emits `res.on("error")` and asserts the
|
|
28
|
+
* call REJECTS — `defaultTransport` now registers a `res.on("error")`
|
|
29
|
+
* listener that rejects, so this settles instead of hanging. (The previous
|
|
30
|
+
* version of the transport omitted that listener; emitting a response-stream
|
|
31
|
+
* error then could never settle the promise, which is the latent hang this
|
|
32
|
+
* fix closes.)
|
|
33
|
+
*/
|
|
34
|
+
import { EventEmitter } from "node:events";
|
|
35
|
+
import type { PeerCertificate } from "node:tls";
|
|
36
|
+
|
|
37
|
+
const realHttps = require("node:https") as typeof import("node:https");
|
|
38
|
+
|
|
39
|
+
/** Minimal stand-in for the response `IncomingMessage` (`data` / `end`). */
|
|
40
|
+
class FakeResponse extends EventEmitter {
|
|
41
|
+
statusCode?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Minimal stand-in for `ClientRequest`. It is an `EventEmitter` so the
|
|
46
|
+
* transport can attach `error` / `timeout` listeners. `write` / `end` record
|
|
47
|
+
* what was sent; `destroy(err)` mirrors Node by surfacing the destroy reason as
|
|
48
|
+
* an `error` event on the next microtask — which is how the production timeout
|
|
49
|
+
* handler ultimately rejects the promise.
|
|
50
|
+
*/
|
|
51
|
+
class FakeClientRequest extends EventEmitter {
|
|
52
|
+
written = "";
|
|
53
|
+
ended = false;
|
|
54
|
+
destroyedWith: unknown = undefined;
|
|
55
|
+
write(chunk: string): boolean {
|
|
56
|
+
this.written += chunk;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
end(): void {
|
|
60
|
+
this.ended = true;
|
|
61
|
+
}
|
|
62
|
+
destroy(err?: unknown): void {
|
|
63
|
+
this.destroyedWith = err;
|
|
64
|
+
if (err) queueMicrotask(() => this.emit("error", err));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What each invocation of the fake `request` captures for the test to drive. */
|
|
69
|
+
type Capture = {
|
|
70
|
+
opts: import("node:https").RequestOptions;
|
|
71
|
+
req: FakeClientRequest;
|
|
72
|
+
cb: (res: FakeResponse) => void;
|
|
73
|
+
res?: FakeResponse;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let lastCall: Capture | undefined;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Behaviour selector, reset to `"manual"` after every test:
|
|
80
|
+
* - "respond": on the next microtask deliver a response, emit `respondChunks`,
|
|
81
|
+
* then `end` (happy path — promise resolves).
|
|
82
|
+
* - "open": on the next microtask deliver a response (so `res.on(...)`
|
|
83
|
+
* listeners are attached) and emit `respondChunks`, but DO NOT
|
|
84
|
+
* emit `end`. The stream is left open for the test to drive — e.g.
|
|
85
|
+
* to emit a response-stream `"error"`. The response is captured in
|
|
86
|
+
* `lastCall.res` so the test can reach it.
|
|
87
|
+
* - "manual": capture `opts` / `req` only; the test drives every event and is
|
|
88
|
+
* responsible for settling the promise (reject via req `error`).
|
|
89
|
+
*/
|
|
90
|
+
let mode: "respond" | "open" | "manual" = "manual";
|
|
91
|
+
let respondStatus: number | undefined = 200;
|
|
92
|
+
let respondChunks: Buffer[] = [];
|
|
93
|
+
|
|
94
|
+
mock.module("node:https", () => ({
|
|
95
|
+
...realHttps,
|
|
96
|
+
request: (opts: import("node:https").RequestOptions, cb: (res: FakeResponse) => void) => {
|
|
97
|
+
const req = new FakeClientRequest();
|
|
98
|
+
const call: Capture = { opts, req, cb };
|
|
99
|
+
lastCall = call;
|
|
100
|
+
if (mode === "respond" || mode === "open") {
|
|
101
|
+
// Deliver the response only after the synchronous body of
|
|
102
|
+
// `defaultTransport` has run `req.write(body)` / `req.end()`.
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
const res = new FakeResponse();
|
|
105
|
+
res.statusCode = respondStatus;
|
|
106
|
+
call.res = res;
|
|
107
|
+
cb(res);
|
|
108
|
+
for (const c of respondChunks) res.emit("data", c);
|
|
109
|
+
// "open" leaves the stream live so the test can drive `error`/`end`.
|
|
110
|
+
if (mode === "respond") res.emit("end");
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return req;
|
|
114
|
+
},
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
// Import AFTER registering the mock so the static `node:https` binding inside
|
|
118
|
+
// `defaultTransport` resolves to the fake.
|
|
119
|
+
const { FEDERATION_VERSION, FederationProtocolError, federationCall, fingerprintCert } =
|
|
120
|
+
await import("./index");
|
|
121
|
+
const { makeFixtureCertSet } = await import("./test-helpers");
|
|
122
|
+
|
|
123
|
+
type Creds = {
|
|
124
|
+
caCertPem: string;
|
|
125
|
+
clientCertPem: string;
|
|
126
|
+
clientKeyPem: string;
|
|
127
|
+
pinnedFingerprint: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let creds: Creds;
|
|
131
|
+
const baseEnvelope = {
|
|
132
|
+
version: FEDERATION_VERSION,
|
|
133
|
+
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
|
|
134
|
+
federation: {
|
|
135
|
+
from: { deployment: "deployment-a", role: "researcher" },
|
|
136
|
+
to: { deployment: "deployment-b", role: "code-reviewer" },
|
|
137
|
+
mtls: { client_cert_subject: "CN=deployment-a" },
|
|
138
|
+
},
|
|
139
|
+
kind: "question" as const,
|
|
140
|
+
payload: "review the patch",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
beforeAll(() => {
|
|
144
|
+
const c = makeFixtureCertSet();
|
|
145
|
+
creds = {
|
|
146
|
+
caCertPem: c.caCertPem,
|
|
147
|
+
clientCertPem: c.clientCertPem,
|
|
148
|
+
clientKeyPem: c.clientKeyPem,
|
|
149
|
+
pinnedFingerprint: c.pinnedFingerprint,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
lastCall = undefined;
|
|
155
|
+
mode = "manual";
|
|
156
|
+
respondStatus = 200;
|
|
157
|
+
respondChunks = [];
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Restore the genuine `node:https` so the stub cannot outlive this file.
|
|
161
|
+
afterAll(() => {
|
|
162
|
+
mock.module("node:https", () => realHttps);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Drive the pinned-cert check for an in-flight call, then settle the call by
|
|
167
|
+
* rejecting it (the production code has no other exit once the request is live
|
|
168
|
+
* and no response is delivered). Returns whatever `checkServerIdentity`
|
|
169
|
+
* returned so the caller can assert on it.
|
|
170
|
+
*/
|
|
171
|
+
async function inspectThenSettle(
|
|
172
|
+
call: Promise<unknown>,
|
|
173
|
+
peer: Partial<PeerCertificate>,
|
|
174
|
+
): Promise<Error | undefined> {
|
|
175
|
+
// Let the synchronous request-assembly body of defaultTransport run.
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
const check = lastCall?.opts.checkServerIdentity;
|
|
178
|
+
expect(typeof check).toBe("function");
|
|
179
|
+
const result = check?.(
|
|
180
|
+
"deployment-b.example",
|
|
181
|
+
peer as unknown as Parameters<NonNullable<typeof check>>[1],
|
|
182
|
+
);
|
|
183
|
+
// Settle the dangling promise so no handle is left open.
|
|
184
|
+
lastCall?.req.emit("error", new Error("teardown"));
|
|
185
|
+
await expect(call).rejects.toThrow();
|
|
186
|
+
return result as Error | undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
describe("defaultTransport (real node:https path, request mocked)", () => {
|
|
190
|
+
test("rejects a non-https url before issuing any request", async () => {
|
|
191
|
+
await expect(
|
|
192
|
+
federationCall({
|
|
193
|
+
url: "http://deployment-b.example/federation",
|
|
194
|
+
envelope: baseEnvelope,
|
|
195
|
+
credentials: creds,
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toThrow(/federation transport requires https:\/\/, got http:/);
|
|
198
|
+
// The guard returns before `httpsRequest` is ever called.
|
|
199
|
+
expect(lastCall).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("happy path POSTs the encoded envelope and resolves with status + body", async () => {
|
|
203
|
+
mode = "respond";
|
|
204
|
+
respondStatus = 200;
|
|
205
|
+
respondChunks = [Buffer.from('{"reply":', "utf8"), Buffer.from('"ack"}', "utf8")];
|
|
206
|
+
|
|
207
|
+
const result = await federationCall({
|
|
208
|
+
url: "https://deployment-b.example:8443/federation?trace=1",
|
|
209
|
+
envelope: baseEnvelope,
|
|
210
|
+
credentials: creds,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual({ status: 200, body: '{"reply":"ack"}' });
|
|
214
|
+
|
|
215
|
+
// Request options were assembled from the URL.
|
|
216
|
+
const opts = lastCall?.opts;
|
|
217
|
+
expect(opts?.method).toBe("POST");
|
|
218
|
+
expect(opts?.protocol).toBe("https:");
|
|
219
|
+
expect(opts?.hostname).toBe("deployment-b.example");
|
|
220
|
+
expect(opts?.port).toBe("8443");
|
|
221
|
+
expect(opts?.path).toBe("/federation?trace=1");
|
|
222
|
+
expect(opts?.rejectUnauthorized).toBe(true);
|
|
223
|
+
expect(opts?.ca).toBe(creds.caCertPem);
|
|
224
|
+
expect(opts?.cert).toBe(creds.clientCertPem);
|
|
225
|
+
expect(opts?.key).toBe(creds.clientKeyPem);
|
|
226
|
+
|
|
227
|
+
// The body written to the socket is the encoded envelope.
|
|
228
|
+
const written = lastCall?.req.written ?? "";
|
|
229
|
+
expect(JSON.parse(written)).toEqual(baseEnvelope);
|
|
230
|
+
expect(lastCall?.req.ended).toBe(true);
|
|
231
|
+
|
|
232
|
+
// Headers reflect the encoded body.
|
|
233
|
+
const headers = opts?.headers as Record<string, string>;
|
|
234
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
235
|
+
expect(headers["Content-Length"]).toBe(Buffer.byteLength(written, "utf8").toString());
|
|
236
|
+
expect(headers["X-Crewhaus-Federation-Version"]).toBe(FEDERATION_VERSION);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("defaults the port to 443 and path to '/' when the url omits them", async () => {
|
|
240
|
+
mode = "respond";
|
|
241
|
+
respondStatus = 204;
|
|
242
|
+
respondChunks = [];
|
|
243
|
+
const result = await federationCall({
|
|
244
|
+
url: "https://deployment-b.example",
|
|
245
|
+
envelope: baseEnvelope,
|
|
246
|
+
credentials: creds,
|
|
247
|
+
});
|
|
248
|
+
expect(result).toEqual({ status: 204, body: "" });
|
|
249
|
+
const opts = lastCall?.opts;
|
|
250
|
+
expect(opts?.port).toBe(443); // `u.port || 443` — empty string → numeric default
|
|
251
|
+
expect(opts?.path).toBe("/"); // empty pathname → "/"
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("resolves status 0 when the response carries no statusCode", async () => {
|
|
255
|
+
mode = "respond";
|
|
256
|
+
respondStatus = undefined; // exercise `res.statusCode ?? 0`
|
|
257
|
+
respondChunks = [Buffer.from("hi", "utf8")];
|
|
258
|
+
const result = await federationCall({
|
|
259
|
+
url: "https://deployment-b.example/federation",
|
|
260
|
+
envelope: baseEnvelope,
|
|
261
|
+
credentials: creds,
|
|
262
|
+
});
|
|
263
|
+
expect(result).toEqual({ status: 0, body: "hi" });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("checkServerIdentity accepts a peer cert whose fingerprint matches the pin", async () => {
|
|
267
|
+
const call = federationCall({
|
|
268
|
+
url: "https://deployment-b.example/federation",
|
|
269
|
+
envelope: baseEnvelope,
|
|
270
|
+
credentials: creds,
|
|
271
|
+
timeoutMs: 5_000,
|
|
272
|
+
});
|
|
273
|
+
// Node yields fingerprint256 colon-separated + upper-case; the transport
|
|
274
|
+
// strips ":" and lower-cases before comparing.
|
|
275
|
+
const pinnedColon = creds.pinnedFingerprint.toUpperCase().replace(/(..)(?=.)/g, "$1:");
|
|
276
|
+
const ok = await inspectThenSettle(call, { fingerprint256: pinnedColon });
|
|
277
|
+
expect(ok).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("checkServerIdentity rejects a peer cert whose fingerprint differs from the pin", async () => {
|
|
281
|
+
const call = federationCall({
|
|
282
|
+
url: "https://deployment-b.example/federation",
|
|
283
|
+
envelope: baseEnvelope,
|
|
284
|
+
credentials: creds,
|
|
285
|
+
});
|
|
286
|
+
const wrong = await inspectThenSettle(call, { fingerprint256: "AA:BB:CC" });
|
|
287
|
+
expect(wrong).toBeInstanceOf(Error);
|
|
288
|
+
expect((wrong as Error).message).toMatch(/cert-pin mismatch/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("checkServerIdentity treats a missing fingerprint256 as '' (mismatch)", async () => {
|
|
292
|
+
const call = federationCall({
|
|
293
|
+
url: "https://deployment-b.example/federation",
|
|
294
|
+
envelope: baseEnvelope,
|
|
295
|
+
credentials: creds,
|
|
296
|
+
});
|
|
297
|
+
// No fingerprint256 → `?? ""` → "" !== pin → Error with empty fp in message.
|
|
298
|
+
const res = await inspectThenSettle(call, {});
|
|
299
|
+
expect(res).toBeInstanceOf(Error);
|
|
300
|
+
expect((res as Error).message).toMatch(/peer fingerprint {2}!= pinned/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("rejects with a FederationProtocolError when the request emits 'error'", async () => {
|
|
304
|
+
const call = federationCall({
|
|
305
|
+
url: "https://deployment-b.example/federation",
|
|
306
|
+
envelope: baseEnvelope,
|
|
307
|
+
credentials: creds,
|
|
308
|
+
});
|
|
309
|
+
await Promise.resolve(); // let the request assemble + write/end
|
|
310
|
+
expect(lastCall?.req.ended).toBe(true);
|
|
311
|
+
lastCall?.req.emit("error", new Error("ECONNREFUSED boom"));
|
|
312
|
+
await expect(call).rejects.toThrow(/federation transport error: ECONNREFUSED boom/);
|
|
313
|
+
await call.catch((e) => {
|
|
314
|
+
expect(e).toBeInstanceOf(FederationProtocolError);
|
|
315
|
+
// The original socket error is preserved as the cause.
|
|
316
|
+
expect((e as InstanceType<typeof FederationProtocolError>).cause).toBeInstanceOf(Error);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("on 'timeout' it destroys the request and rejects with the configured timeout", async () => {
|
|
321
|
+
const call = federationCall({
|
|
322
|
+
url: "https://deployment-b.example/federation",
|
|
323
|
+
envelope: baseEnvelope,
|
|
324
|
+
credentials: creds,
|
|
325
|
+
timeoutMs: 1234,
|
|
326
|
+
});
|
|
327
|
+
await Promise.resolve();
|
|
328
|
+
expect(lastCall?.opts.timeout).toBe(1234);
|
|
329
|
+
// Fire the socket timeout: the handler calls req.destroy(err); our fake
|
|
330
|
+
// surfaces that destroy reason as an `error` event, which rejects.
|
|
331
|
+
lastCall?.req.emit("timeout");
|
|
332
|
+
await expect(call).rejects.toThrow(/federation transport timeout after 1234ms/);
|
|
333
|
+
expect(lastCall?.req.destroyedWith).toBeInstanceOf(FederationProtocolError);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("uses the default 30s timeout when none is supplied", async () => {
|
|
337
|
+
const call = federationCall({
|
|
338
|
+
url: "https://deployment-b.example/federation",
|
|
339
|
+
envelope: baseEnvelope,
|
|
340
|
+
credentials: creds,
|
|
341
|
+
});
|
|
342
|
+
await Promise.resolve();
|
|
343
|
+
expect(lastCall?.opts.timeout).toBe(30_000);
|
|
344
|
+
// Settle so no handle dangles.
|
|
345
|
+
lastCall?.req.emit("error", new Error("teardown"));
|
|
346
|
+
await expect(call).rejects.toThrow();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("fingerprintCert agrees with the pin the transport compares against", () => {
|
|
350
|
+
// Anchors the colon-stripping / lower-casing contract checkServerIdentity
|
|
351
|
+
// relies on: the helper and the pin must agree on the same canonical form.
|
|
352
|
+
expect(fingerprintCert(creds.clientCertPem)).toBe(creds.pinnedFingerprint);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("rejects with a FederationProtocolError when the response stream emits 'error'", async () => {
|
|
356
|
+
// Regression guard for the latent hang: if the response stream errors
|
|
357
|
+
// mid-body, `defaultTransport` must reject (settle) — never leave the
|
|
358
|
+
// returned promise pending. "open" delivers a live `res` (so the
|
|
359
|
+
// production `res.on("error")` listener is attached) and a partial body,
|
|
360
|
+
// then hands control to the test.
|
|
361
|
+
mode = "open";
|
|
362
|
+
respondStatus = 200;
|
|
363
|
+
respondChunks = [Buffer.from('{"partial":', "utf8")];
|
|
364
|
+
const call = federationCall({
|
|
365
|
+
url: "https://deployment-b.example/federation",
|
|
366
|
+
envelope: baseEnvelope,
|
|
367
|
+
credentials: creds,
|
|
368
|
+
});
|
|
369
|
+
// Let the request assemble + the queued microtask deliver `res` and the
|
|
370
|
+
// partial chunk, so the response stream is live with listeners attached.
|
|
371
|
+
await Promise.resolve();
|
|
372
|
+
await Promise.resolve();
|
|
373
|
+
expect(lastCall?.res).toBeInstanceOf(EventEmitter);
|
|
374
|
+
// Mid-body stream failure: without `res.on("error")` this would hang.
|
|
375
|
+
lastCall?.res?.emit("error", new Error("ECONNRESET mid-body"));
|
|
376
|
+
await expect(call).rejects.toThrow(/federation transport error: ECONNRESET mid-body/);
|
|
377
|
+
await call.catch((e) => {
|
|
378
|
+
expect(e).toBeInstanceOf(FederationProtocolError);
|
|
379
|
+
// The original stream error is preserved as the cause.
|
|
380
|
+
expect((e as InstanceType<typeof FederationProtocolError>).cause).toBeInstanceOf(Error);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -250,6 +250,9 @@ function defaultTransport(timeoutMs: number): FederationTransport {
|
|
|
250
250
|
const req = httpsRequest(opts, (res) => {
|
|
251
251
|
const chunks: Buffer[] = [];
|
|
252
252
|
res.on("data", (c) => chunks.push(c as Buffer));
|
|
253
|
+
res.on("error", (err) =>
|
|
254
|
+
reject(new FederationProtocolError(`federation transport error: ${err.message}`, err)),
|
|
255
|
+
);
|
|
253
256
|
res.on("end", () =>
|
|
254
257
|
resolve({
|
|
255
258
|
status: res.statusCode ?? 0,
|