1import * as NodeNet from "node:net";
2
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Context from "effect/Context";
6import * as Predicate from "effect/Predicate";
7import * as Schema from "effect/Schema";
8
9export class NetError extends Schema.TaggedError<NetError>()("NetError", {
10 message: Schema.String,
11 cause: Schema.optional(Schema.Defect()),
12}) {}
13
14const isErrnoExceptionWithCode = (
15 cause: unknown,
16): cause is {
17 readonly code: string;
18} =>
19 Predicate.isObject(cause) &&
20 Predicate.hasProperty(cause, "code") &&
21 Predicate.isString(cause.code);
22
23const closeServer = (server: NodeNet.Server) => {
24 try {
25 server.close();
26 } catch {}
27};
28
29export interface NetServiceShape {
30 readonly canListenOnHost: (port: number, host: string) => Effect.Effect<boolean>;
31
32 readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>;
33
34 readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect<boolean>;
35
36 readonly reserveLoopbackPort: (host?: string) => Effect.Effect<number, NetError>;
37
38 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>;
39}
40
41export class NetService extends Context.Service<NetService, NetServiceShape>()(
42 "@t3tools/shared/Net/NetService",
43) {}
44
45/** @public */
46export const make = () => {
47 const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> =>
48 Effect.callback<boolean>((resume) => {
49 const server = NodeNet.createServer();
50 let settled = false;
51
52 const settle = (value: boolean) => {
53 if (settled) return;
54 settled = true;
55 resume(Effect.succeed(value));
56 };
57
58 server.unref();
59
60 server.once("error", (cause) => {
61 // Missing IPv6 addresses or kernel support do not mean the port is occupied.
62 if (
63 isErrnoExceptionWithCode(cause) &&
64 (cause.code === "EADDRNOTAVAIL" || cause.code === "EAFNOSUPPORT")
65 ) {
66 settle(true);
67 return;
68 }
69 settle(false);
70 });
71
72 server.once("listening", () => {
73 server.close(() => {
74 settle(true);
75 });
76 });
77
78 server.listen({ host, port });
79
80 return Effect.sync(() => {
81 closeServer(server);
82 });
83 });
84
85 const hasListenerOnHost = (port: number, host: string): Effect.Effect<boolean> =>
86 Effect.callback<boolean>((resume) => {
87 const socket = NodeNet.createConnection({ host, port });
88 let settled = false;
89
90 const settle = (value: boolean) => {
91 if (settled) return;
92 settled = true;
93 socket.destroy();
94 resume(Effect.succeed(value));
95 };
96
97 socket.unref();
98 socket.setTimeout(250);
99 socket.once("connect", () => {
100 settle(true);
101 });
102 socket.once("error", () => {
103 settle(false);
104 });
105 socket.once("timeout", () => {
106 settle(false);
107 });
108
109 return Effect.sync(() => {
110 socket.destroy();
111 });
112 });
113
114 const isPortAvailableOnLoopback = Effect.fn("Net.isPortAvailableOnLoopback")(function* (
115 port: number,
116 ): Effect.fn.Return<boolean> {
117 const hasListener = yield* Effect.zipWith(
118 hasListenerOnHost(port, "127.0.0.1"),
119 hasListenerOnHost(port, "::1"),
120 (ipv4, ipv6) => ipv4 || ipv6,
121 );
122 if (hasListener) {
123 return false;
124 }
125
126 return yield* Effect.zipWith(
127 canListenOnHost(port, "127.0.0.1"),
128 canListenOnHost(port, "::1"),
129 (ipv4, ipv6) => ipv4 && ipv6,
130 );
131 });
132
133 const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> =>
134 Effect.callback<number, NetError>((resume) => {
135 const probe = NodeNet.createServer();
136 let settled = false;
137
138 const settle = (effect: Effect.Effect<number, NetError>) => {
139 if (settled) return;
140 settled = true;
141 resume(effect);
142 };
143
144 probe.once("error", (cause) => {
145 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause })));
146 });
147
148 probe.listen(0, host, () => {
149 const address = probe.address();
150 const port = typeof address === "object" && address !== null ? address.port : 0;
151 probe.close(() => {
152 if (port > 0) {
153 settle(Effect.succeed(port));
154 return;
155 }
156 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" })));
157 });
158 });
159
160 return Effect.sync(() => {
161 closeServer(probe);
162 });
163 });
164
165 return {
166 canListenOnHost,
167 isPortAvailableOnLoopback,
168 hasListenerOnHost,
169 reserveLoopbackPort,
170 findAvailablePort: Effect.fn("Net.findAvailablePort")(function* (preferred) {
171 if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) {
172 return preferred;
173 }
174 return yield* reserveLoopbackPort();
175 }),
176 } satisfies NetServiceShape;
177};
178
179export const layer = Layer.sync(NetService, make);