Skip to content
Source
Sign inSign up
Open History to see the last commit

outboundHttp.test.ts

166 lines6.5 KB

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