Repository navigation
Expand file tree
/
Copy pathoutboundHttp.test.ts
More file actions
166 lines (145 loc) · 6.53 KB
/
Copy pathoutboundHttp.test.ts
File metadata and controls
166 lines (145 loc) · 6.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
// @effect-diagnostics nodeBuiltinImport:off - the test runs a real TLS h2 server to see which protocol fetch negotiates.
import * as NodeHttp2 from "node:http2";
import * as NodeNet from "node:net";
import * as NodeTls from "node:tls";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import { FetchHttpClient, HttpClient } from "effect/http";
import * as Undici from "undici";
import { afterAll, afterEach, beforeAll, describe, expect } from "vite-plus/test";
import {
builtinFetchUsesHttp2,
ignoreMacosTypeOfServiceEinval,
installHttp1FetchDispatcher,
} from "./outboundHttp.ts";
// Non-secret, self-signed 127.0.0.1 certificate, valid until 2126; ALPN h2 requires TLS.
const TEST_KEY = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3EwCztQysKTvzdcR
g0LWq+axzUd4tHi9PyMdqh7PlGShRANCAARfQpoLgv/tOsHWCLriDza7Ruaey2b7
mR/nS6mLu6W7+agkEqDm0mE3F/ngpPjL4Jj0IGadeMcKNKFRSBgiLdfn
-----END PRIVATE KEY-----
`;
const TEST_CERT = `-----BEGIN CERTIFICATE-----
MIIBsDCCAVagAwIBAgIUWbn2Wt5kTxii1UU57NJMt/wTaUgwCgYIKoZIzj0EAwIw
JDEiMCAGA1UEAwwZdDNjb2RlIG91dGJvdW5kIGh0dHAgdGVzdDAgFw0yNjEwMTEy
MjQyMzZaGA8yMTI2MDkxNzIyNDIzNlowJDEiMCAGA1UEAwwZdDNjb2RlIG91dGJv
dW5kIGh0dHAgdGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABF9CmguC/+06
wdYIuuIPNrtG5p7LZvuZH+dLqYu7pbv5qCQSoObSYTcX+eCk+MvgmPQgZp14xwo0
oVFIGCIt1+ejZDBiMB0GA1UdDgQWBBSsekAc30Q/zdIE/tvVDQKJL5oiCDAfBgNV
HSMEGDAWgBSsekAc30Q/zdIE/tvVDQKJL5oiCDAPBgNVHRMBAf8EBTADAQH/MA8G
A1UdEQQIMAaHBH8AAAEwCgYIKoZIzj0EAwIDSAAwRQIgebUrkyP56242EJB7nCV7
A+Ke/q0rNPya3fjQa9O5B+kCIQC1SfmdNaFcNJ4JINlus4FfrRnfKiADS2MIJR9f
99BN0A==
-----END CERTIFICATE-----
`;
const server = NodeHttp2.createSecureServer(
{ allowHTTP1: true, key: TEST_KEY, cert: TEST_CERT },
(request, response) => response.end(request.httpVersion),
);
let origin = "";
const defaultCaCertificates = NodeTls.getCACertificates("default");
const defaultDispatcher = Undici.getGlobalDispatcher();
const defaultAttemptTimeout = NodeNet.getDefaultAutoSelectFamilyAttemptTimeout();
beforeAll(async () => {
NodeTls.setDefaultCACertificates([...defaultCaCertificates, TEST_CERT]);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
origin = `https://127.0.0.1:${(server.address() as NodeNet.AddressInfo).port}/`;
});
afterEach(() => {
Undici.setGlobalDispatcher(defaultDispatcher);
NodeNet.setDefaultAutoSelectFamilyAttemptTimeout(defaultAttemptTimeout);
});
afterAll(async () => {
NodeTls.setDefaultCACertificates(defaultCaCertificates);
await new Promise<void>((resolve) => server.close(() => resolve()));
});
const versionViaGlobalDispatcher = async () => (await Undici.request(origin)).body.text();
const versionViaBuiltinFetch = () =>
HttpClient.get(origin).pipe(
Effect.flatMap((response) => response.text),
Effect.provide(FetchHttpClient.layer),
);
describe("installHttp1FetchDispatcher", () => {
it("makes the global dispatcher negotiate HTTP/1.1 and keeps the env proxy agent", async () => {
Undici.setGlobalDispatcher(new Undici.Agent());
expect(await versionViaGlobalDispatcher()).toBe("2.0");
installHttp1FetchDispatcher();
expect(await versionViaGlobalDispatcher()).toBe("1.1");
Undici.setGlobalDispatcher(new Undici.EnvHttpProxyAgent());
installHttp1FetchDispatcher();
expect(Undici.getGlobalDispatcher()).toBeInstanceOf(Undici.EnvHttpProxyAgent);
});
// Only bundled undici 8 (Node 26+) offers fetch h2 and can prove dispatcher replacement.
it.effect.runIf(builtinFetchUsesHttp2(process.versions.undici))(
"moves the built-in fetch off HTTP/2",
() =>
Effect.gen(function* () {
expect(yield* versionViaBuiltinFetch()).toBe("2.0");
installHttp1FetchDispatcher();
expect(yield* versionViaBuiltinFetch()).toBe("1.1");
}),
);
});
const runNode = Effect.fnUntraced(function* (args: Array<string>) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const child = yield* spawner.spawn(ChildProcess.make(process.execPath, args));
return yield* Effect.all(
{
stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString),
stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString),
exitCode: child.exitCode.pipe(Effect.map(Number)),
},
{ concurrency: "unbounded" },
);
}, Effect.scoped);
describe("ignoreMacosTypeOfServiceEinval", () => {
const errnoError = (code: string) =>
Object.assign(new Error(`setTypeOfService ${code}`), { code, syscall: "setTypeOfService" });
it("swallows only an Error with code EINVAL on macOS", () => {
let error: unknown = errnoError("EINVAL");
const prototype = {
setTypeOfService(this: NodeNet.Socket, _typeOfService: number): NodeNet.Socket {
throw error;
},
};
ignoreMacosTypeOfServiceEinval("darwin", prototype);
const socket = new NodeNet.Socket();
expect(prototype.setTypeOfService.call(socket, 0)).toBe(socket);
error = errnoError("EPERM");
expect(() => prototype.setTypeOfService.call(socket, 0)).toThrow(error);
error = { code: "EINVAL" };
expect(() => prototype.setTypeOfService.call(socket, 0)).toThrow();
});
// Bundled undici 7 calls this API from a socket callback. Keep the real fetch
// in a child so an escaped EINVAL exits that process instead of breaking Vitest.
it.effect.runIf(
typeof (NodeNet.Socket.prototype as { setTypeOfService?: unknown }).setTypeOfService ===
"function" && process.versions.undici?.startsWith("7.") === true,
)("keeps built-in fetch working when setTypeOfService throws EINVAL", () =>
Effect.gen(function* () {
const child = yield* runNode([
"--input-type=module",
"--eval",
`
import * as NodeNet from "node:net";
import * as NodeTls from "node:tls";
import { ignoreMacosTypeOfServiceEinval } from ${JSON.stringify(new URL("./outboundHttp.ts", import.meta.url).href)};
NodeTls.setDefaultCACertificates([${JSON.stringify(TEST_CERT)}]);
let calls = 0;
NodeNet.Socket.prototype.setTypeOfService = () => {
calls++;
throw Object.assign(new Error("setTypeOfService EINVAL"), { code: "EINVAL" });
};
ignoreMacosTypeOfServiceEinval("darwin");
console.log(await (await fetch(${JSON.stringify(origin)})).text());
if (calls === 0) throw new Error("fetch did not call setTypeOfService");
`,
]);
expect(child.exitCode, child.stderr).toBe(0);
expect(child.stdout.trim()).toBe("1.1");
}).pipe(Effect.provide(NodeServices.layer)),
);
});