1import * as NodeNet from "node:net";
2
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Context from "effect/Context";
7import * as Predicate from "effect/Predicate";
8
9export class NetError extends Data.TaggedError("NetError")<{
10 readonly message: string;
11 readonly cause?: unknown;
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 // Ignore close failures during cleanup.
28 }
29};
30
31export interface NetServiceShape {
32 /**
33 * Returns true when a TCP server can bind to {host, port}.
34 */
35 readonly canListenOnHost: (port: number, host: string) => Effect.Effect<boolean>;
36
37 /**
38 * Checks loopback availability on both IPv4 and IPv6 localhost addresses.
39 */
40 readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>;
41
42 /**
43 * Returns true when something accepts TCP connections on {host, port}.
44 * Unlike the bind-side checks this works for privileged ports (<1024).
45 */
46 readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect<boolean>;
47
48 /**
49 * Reserve an ephemeral loopback port and release it immediately.
50 */
51 readonly reserveLoopbackPort: (host?: string) => Effect.Effect<number, NetError>;
52
53 /**
54 * Resolve an available listening port, preferring the provided port first.
55 */
56 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>;
57}
58
59/**
60 * NetService - Service tag for startup networking helpers.
61 */
62export class NetService extends Context.Service<NetService, NetServiceShape>()(
63 "@t3tools/shared/Net/NetService",
64) {}
65
66/** @public Service construction is part of the canonical Effect module API. */
67export const make = () => {
68 /**
69 * Returns true when a TCP server can bind to {host, port}.
70 * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail
71 * loopback availability checks.
72 */
73 const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> =>
74 Effect.callback<boolean>((resume) => {
75 const server = NodeNet.createServer();
76 let settled = false;
77
78 const settle = (value: boolean) => {
79 if (settled) return;
80 settled = true;
81 resume(Effect.succeed(value));
82 };
83
84 server.unref();
85
86 server.once("error", (cause) => {
87 if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") {
88 settle(true);
89 return;
90 }
91 settle(false);
92 });
93
94 server.once("listening", () => {
95 server.close(() => {
96 settle(true);
97 });
98 });
99
100 server.listen({ host, port });
101
102 return Effect.sync(() => {
103 closeServer(server);
104 });
105 });
106
107 const hasListenerOnHost = (port: number, host: string): Effect.Effect<boolean> =>
108 Effect.callback<boolean>((resume) => {
109 const socket = NodeNet.createConnection({ host, port });
110 let settled = false;
111
112 const settle = (value: boolean) => {
113 if (settled) return;
114 settled = true;
115 socket.destroy();
116 resume(Effect.succeed(value));
117 };
118
119 socket.unref();
120 socket.setTimeout(250);
121 socket.once("connect", () => {
122 settle(true);
123 });
124 socket.once("error", () => {
125 settle(false);
126 });
127 socket.once("timeout", () => {
128 settle(false);
129 });
130
131 return Effect.sync(() => {
132 socket.destroy();
133 });
134 });
135
136 const isPortAvailableOnLoopback = (port: number): Effect.Effect<boolean> =>
137 Effect.gen(function* () {
138 const hasListener = yield* Effect.zipWith(
139 hasListenerOnHost(port, "127.0.0.1"),
140 hasListenerOnHost(port, "::1"),
141 (ipv4, ipv6) => ipv4 || ipv6,
142 );
143 if (hasListener) {
144 return false;
145 }
146
147 return yield* Effect.zipWith(
148 canListenOnHost(port, "127.0.0.1"),
149 canListenOnHost(port, "::1"),
150 (ipv4, ipv6) => ipv4 && ipv6,
151 );
152 });
153
154 /**
155 * Reserve an ephemeral loopback port and release it immediately.
156 * Returns the reserved port number.
157 */
158 const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> =>
159 Effect.callback<number, NetError>((resume) => {
160 const probe = NodeNet.createServer();
161 let settled = false;
162
163 const settle = (effect: Effect.Effect<number, NetError>) => {
164 if (settled) return;
165 settled = true;
166 resume(effect);
167 };
168
169 probe.once("error", (cause) => {
170 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause })));
171 });
172
173 probe.listen(0, host, () => {
174 const address = probe.address();
175 const port = typeof address === "object" && address !== null ? address.port : 0;
176 probe.close(() => {
177 if (port > 0) {
178 settle(Effect.succeed(port));
179 return;
180 }
181 settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" })));
182 });
183 });
184
185 return Effect.sync(() => {
186 closeServer(probe);
187 });
188 });
189
190 return {
191 canListenOnHost,
192 isPortAvailableOnLoopback,
193 hasListenerOnHost,
194 reserveLoopbackPort,
195 findAvailablePort: (preferred) =>
196 Effect.gen(function* () {
197 if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) {
198 return preferred;
199 }
200 return yield* reserveLoopbackPort();
201 }),
202 } satisfies NetServiceShape;
203};
204
205export const layer = Layer.sync(NetService, make);