@jsenv/server 13.1.0 → 14.0.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "13.1.0",
3
+ "version": "14.0.0",
4
4
  "description": "Write your Node.js server using pure functions",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -0,0 +1,48 @@
1
+ import { isIP } from "node:net"
2
+
3
+ export const parseHostname = (hostname) => {
4
+ if (hostname === "0.0.0.0") {
5
+ return {
6
+ type: "ip",
7
+ label: "unspecified",
8
+ version: 4,
9
+ }
10
+ }
11
+ if (
12
+ hostname === "::" ||
13
+ hostname === "0000:0000:0000:0000:0000:0000:0000:0000"
14
+ ) {
15
+ return {
16
+ type: "ip",
17
+ label: "unspecified",
18
+ version: 6,
19
+ }
20
+ }
21
+ if (hostname === "127.0.0.1") {
22
+ return {
23
+ type: "ip",
24
+ label: "loopback",
25
+ version: 4,
26
+ }
27
+ }
28
+ if (
29
+ hostname === "::1" ||
30
+ hostname === "0000:0000:0000:0000:0000:0000:0000:0001"
31
+ ) {
32
+ return {
33
+ type: "ip",
34
+ label: "loopback",
35
+ version: 6,
36
+ }
37
+ }
38
+ const ipVersion = isIP(hostname)
39
+ if (ipVersion === 0) {
40
+ return {
41
+ type: "hostname",
42
+ }
43
+ }
44
+ return {
45
+ type: "ip",
46
+ version: ipVersion,
47
+ }
48
+ }
@@ -6,7 +6,7 @@ const listen = async ({
6
6
  server,
7
7
  port,
8
8
  portHint,
9
- host,
9
+ hostname,
10
10
  }) => {
11
11
  const listeningOperation = Abort.startOperation()
12
12
 
@@ -17,11 +17,11 @@ const listen = async ({
17
17
  listeningOperation.throwIfAborted()
18
18
  port = await findFreePort(portHint, {
19
19
  signal: listeningOperation.signal,
20
- host,
20
+ hostname,
21
21
  })
22
22
  }
23
23
  listeningOperation.throwIfAborted()
24
- port = await startListening({ server, port, host })
24
+ port = await startListening({ server, port, hostname })
25
25
  listeningOperation.addAbortCallback(() => stopListening(server))
26
26
  listeningOperation.throwIfAborted()
27
27
 
@@ -35,7 +35,7 @@ export const findFreePort = async (
35
35
  initialPort = 1,
36
36
  {
37
37
  signal = new AbortController().signal,
38
- host = "127.0.0.1",
38
+ hostname = "127.0.0.1",
39
39
  min = 1,
40
40
  max = 65534,
41
41
  next = (port) => port + 1,
@@ -56,26 +56,26 @@ export const findFreePort = async (
56
56
  const nextPort = next(port)
57
57
  if (nextPort > max) {
58
58
  throw new Error(
59
- `${host} has no available port between ${min} and ${max}`,
59
+ `${hostname} has no available port between ${min} and ${max}`,
60
60
  )
61
61
  }
62
- return testUntil(nextPort, host)
62
+ return testUntil(nextPort, hostname)
63
63
  }
64
- const freePort = await testUntil(initialPort, host)
64
+ const freePort = await testUntil(initialPort, hostname)
65
65
  return freePort
66
66
  } finally {
67
67
  await findFreePortOperation.end()
68
68
  }
69
69
  }
70
70
 
71
- const portIsFree = async (port, host) => {
71
+ const portIsFree = async (port, hostname) => {
72
72
  const server = createServer()
73
73
 
74
74
  try {
75
75
  await startListening({
76
76
  server,
77
77
  port,
78
- host,
78
+ hostname,
79
79
  })
80
80
  } catch (error) {
81
81
  if (error && error.code === "EADDRINUSE") {
@@ -91,7 +91,7 @@ const portIsFree = async (port, host) => {
91
91
  return true
92
92
  }
93
93
 
94
- const startListening = ({ server, port, host }) => {
94
+ const startListening = ({ server, port, hostname }) => {
95
95
  return new Promise((resolve, reject) => {
96
96
  server.on("error", reject)
97
97
  server.on("listening", () => {
@@ -99,7 +99,7 @@ const startListening = ({ server, port, host }) => {
99
99
  // https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback
100
100
  resolve(server.address().port)
101
101
  })
102
- server.listen(port, host)
102
+ server.listen(port, hostname)
103
103
  })
104
104
  }
105
105
 
@@ -0,0 +1,49 @@
1
+ import { networkInterfaces } from "node:os"
2
+
3
+ export const createIpGetters = () => {
4
+ const networkAddresses = []
5
+ const networkInterfaceMap = networkInterfaces()
6
+ for (const key of Object.keys(networkInterfaceMap)) {
7
+ for (const networkAddress of networkInterfaceMap[key]) {
8
+ networkAddresses.push(networkAddress)
9
+ }
10
+ }
11
+ return {
12
+ getFirstInternalIp: ({ preferIpv6 }) => {
13
+ const isPref = preferIpv6 ? isIpV6 : isIpV4
14
+ let firstInternalIp
15
+ for (const networkAddress of networkAddresses) {
16
+ if (networkAddress.internal) {
17
+ firstInternalIp = networkAddress.address
18
+ if (isPref(networkAddress)) {
19
+ break
20
+ }
21
+ }
22
+ }
23
+ return firstInternalIp
24
+ },
25
+ getFirstExternalIp: ({ preferIpv6 }) => {
26
+ const isPref = preferIpv6 ? isIpV6 : isIpV4
27
+ let firstExternalIp
28
+ for (const networkAddress of networkAddresses) {
29
+ if (!networkAddress.internal) {
30
+ firstExternalIp = networkAddress.address
31
+ if (isPref(networkAddress)) {
32
+ break
33
+ }
34
+ }
35
+ }
36
+ return firstExternalIp
37
+ },
38
+ }
39
+ }
40
+
41
+ const isIpV4 = (networkAddress) => {
42
+ // node 18.5
43
+ if (typeof networkAddress.family === "number") {
44
+ return networkAddress.family === 4
45
+ }
46
+ return networkAddress.family === "IPv4"
47
+ }
48
+
49
+ const isIpV6 = (networkAddress) => !isIpV4(networkAddress)
@@ -1,3 +1,4 @@
1
+ import { isIP } from "node:net"
1
2
  import http from "node:http"
2
3
  import cluster from "node:cluster"
3
4
  import { createDetailedMessage, createLogger } from "@jsenv/log"
@@ -23,7 +24,6 @@ import {
23
24
  statusToType,
24
25
  colorizeResponseStatus,
25
26
  } from "./internal/colorizeResponseStatus.js"
26
- import { getServerOrigins } from "./internal/getServerOrigins.js"
27
27
  import { listen, stopListening } from "./internal/listen.js"
28
28
  import { composeTwoResponses } from "./internal/response_composition.js"
29
29
  import { listenRequest } from "./internal/listenRequest.js"
@@ -40,6 +40,10 @@ import {
40
40
  } from "./stopReasons.js"
41
41
  import { composeTwoHeaders } from "./internal/headers_composition.js"
42
42
 
43
+ import { createIpGetters } from "./internal/server_ips.js"
44
+ import { parseHostname } from "./internal/hostname_parser.js"
45
+ import { applyDnsResolution } from "./internal/dns_resolution.js"
46
+
43
47
  export const startServer = async ({
44
48
  signal = new AbortController().signal,
45
49
  logLevel,
@@ -52,7 +56,8 @@ export const startServer = async ({
52
56
  redirectHttpToHttps,
53
57
  allowHttpRequestOnHttps = false,
54
58
  acceptAnyIp = false,
55
- host = acceptAnyIp ? undefined : "localhost",
59
+ preferIpv6,
60
+ hostname = "localhost",
56
61
  port = 0, // assign a random available port
57
62
  portHint,
58
63
  privateKey,
@@ -83,6 +88,8 @@ export const startServer = async ({
83
88
  )
84
89
  },
85
90
  } = {}) => {
91
+ const logger = createLogger({ logLevel })
92
+
86
93
  if (protocol !== "http" && protocol !== "https") {
87
94
  throw new Error(`protocol must be http or https, got ${protocol}`)
88
95
  }
@@ -98,7 +105,6 @@ export const startServer = async ({
98
105
  throw new Error(`http2 needs "https" but protocol is "${protocol}"`)
99
106
  }
100
107
 
101
- const logger = createLogger({ logLevel })
102
108
  if (
103
109
  redirectHttpToHttps === undefined &&
104
110
  protocol === "https" &&
@@ -137,6 +143,9 @@ export const startServer = async ({
137
143
  let nodeServer
138
144
  const startServerOperation = Abort.startOperation()
139
145
  const stopCallbackList = createCallbackListNotifiedOnce()
146
+ const serverOrigins = {
147
+ local: "", // favors hostname when possible
148
+ }
140
149
 
141
150
  try {
142
151
  startServerOperation.addAbortSignal(signal)
@@ -163,12 +172,73 @@ export const startServer = async ({
163
172
  nodeServer.unref()
164
173
  }
165
174
 
175
+ const createOrigin = (hostname) => {
176
+ if (isIP(hostname) === 6) {
177
+ return `${protocol}://[${hostname}]`
178
+ }
179
+ return `${protocol}://${hostname}`
180
+ }
181
+
182
+ const ipGetters = createIpGetters()
183
+ let hostnameToListen
184
+ if (acceptAnyIp) {
185
+ const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 })
186
+ serverOrigins.local = createOrigin(firstInternalIp)
187
+ serverOrigins.localip = createOrigin(firstInternalIp)
188
+ const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 })
189
+ serverOrigins.externalip = createOrigin(firstExternalIp)
190
+ hostnameToListen = preferIpv6 ? "::" : "0.0.0.0"
191
+ } else {
192
+ hostnameToListen = hostname
193
+ }
194
+ const hostnameInfo = parseHostname(hostname)
195
+ if (hostnameInfo.type === "ip") {
196
+ if (acceptAnyIp) {
197
+ throw new Error(
198
+ `hostname cannot be an ip when acceptAnyIp is enabled, got ${hostname}`,
199
+ )
200
+ }
201
+
202
+ preferIpv6 = hostnameInfo.version === 6
203
+ const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 })
204
+ serverOrigins.local = createOrigin(firstInternalIp)
205
+ serverOrigins.localip = createOrigin(firstInternalIp)
206
+ if (hostnameInfo.label === "unspecified") {
207
+ const firstExternalIp = ipGetters.getFirstExternalIp({ preferIpv6 })
208
+ serverOrigins.externalip = createOrigin(firstExternalIp)
209
+ } else if (hostnameInfo.label === "loopback") {
210
+ } else {
211
+ serverOrigins.local = createOrigin(hostname)
212
+ }
213
+ } else {
214
+ const hostnameDnsResolution = await applyDnsResolution(hostname, {
215
+ verbatim: true,
216
+ })
217
+ if (hostnameDnsResolution) {
218
+ const hostnameIp = hostnameDnsResolution.address
219
+ serverOrigins.localip = createOrigin(hostnameIp)
220
+ serverOrigins.local = createOrigin(hostname)
221
+ } else {
222
+ const firstInternalIp = ipGetters.getFirstInternalIp({ preferIpv6 })
223
+ // fallback to internal ip because there is no ip
224
+ // associated to this hostname on operating system (in hosts file)
225
+ hostname = firstInternalIp
226
+ hostnameToListen = firstInternalIp
227
+ serverOrigins.local = createOrigin(firstInternalIp)
228
+ }
229
+ }
230
+
166
231
  port = await listen({
167
232
  signal: startServerOperation.signal,
168
233
  server: nodeServer,
169
234
  port,
170
235
  portHint,
171
- host,
236
+ hostname: hostnameToListen,
237
+ })
238
+
239
+ // normalize origins (remove :80 when port is 80 for instance)
240
+ Object.keys(serverOrigins).forEach((key) => {
241
+ serverOrigins[key] = new URL(`${serverOrigins[key]}:${port}`).origin
172
242
  })
173
243
 
174
244
  serviceController.callHooks("serverListening", { port })
@@ -180,6 +250,18 @@ export const startServer = async ({
180
250
  await startServerOperation.end()
181
251
  }
182
252
 
253
+ // the main server origin
254
+ // - when protocol is http
255
+ // node-fetch do not apply local dns resolution to map localhost back to 127.0.0.1
256
+ // despites localhost being mapped so we prefer to use the internal ip
257
+ // (127.0.0.1)
258
+ // - when protocol is https
259
+ // using the hostname becomes important because the certificate is generated
260
+ // for hostnames, not for ips
261
+ // so we prefer https://locahost or https://local_hostname
262
+ // over the ip
263
+ const serverOrigin = serverOrigins.local
264
+
183
265
  // now the server is started (listening) it cannot be aborted anymore
184
266
  // (otherwise an AbortError is thrown to the code calling "startServer")
185
267
  // we can proceed to create a stop function to stop it gacefully
@@ -218,8 +300,6 @@ export const startServer = async ({
218
300
  }
219
301
 
220
302
  status = "opened"
221
- const serverOrigins = await getServerOrigins({ protocol, host, port })
222
- const serverOrigin = serverOrigins.local
223
303
 
224
304
  const removeConnectionErrorListener = listenServerConnectionError(
225
305
  nodeServer,
@@ -829,7 +909,9 @@ export const startServer = async ({
829
909
  const { WebSocketServer } = await import("ws")
830
910
  let websocketServer = new WebSocketServer({ noServer: true })
831
911
  const websocketOrigin =
832
- protocol === "https" ? `wss://${host}:${port}` : `ws://${host}:${port}`
912
+ protocol === "https"
913
+ ? `wss://${hostname}:${port}`
914
+ : `ws://${hostname}:${port}`
833
915
  server.websocketOrigin = websocketOrigin
834
916
  const upgradeCallback = (nodeRequest, socket, head) => {
835
917
  websocketServer.handleUpgrade(
@@ -884,6 +966,7 @@ export const startServer = async ({
884
966
  Object.assign(server, {
885
967
  getStatus: () => status,
886
968
  port,
969
+ hostname,
887
970
  origin: serverOrigin,
888
971
  origins: serverOrigins,
889
972
  nodeServer,
@@ -1,74 +0,0 @@
1
- import { networkInterfaces } from "node:os"
2
-
3
- import { applyDnsResolution } from "./dns_resolution.js"
4
-
5
- export const getServerOrigins = async ({ protocol, host, port }) => {
6
- const isLocal = LOOPBACK_HOSTNAMES.includes(host)
7
- const localhostDnsResolution = await applyDnsResolution("localhost")
8
- const localOrigin = createServerOrigin({
9
- protocol,
10
- hostname:
11
- localhostDnsResolution.address === "127.0.0.1"
12
- ? "localhost"
13
- : "127.0.0.1",
14
- port,
15
- })
16
- if (isLocal) {
17
- return { local: localOrigin }
18
- }
19
- const isAnyIp = WILDCARD_HOSTNAMES.includes(host)
20
- const networkOrigin = createServerOrigin({
21
- protocol,
22
- hostname: isAnyIp ? getExternalIp() : host,
23
- port,
24
- })
25
- return {
26
- local: localOrigin,
27
- network: networkOrigin,
28
- }
29
- }
30
-
31
- const LOOPBACK_HOSTNAMES = [
32
- "localhost",
33
- "127.0.0.1",
34
- "::1",
35
- "0000:0000:0000:0000:0000:0000:0000:0001",
36
- ]
37
-
38
- const WILDCARD_HOSTNAMES = [
39
- undefined,
40
- "0.0.0.0",
41
- "::",
42
- "0000:0000:0000:0000:0000:0000:0000:0000",
43
- ]
44
-
45
- const createServerOrigin = ({ protocol, hostname, port }) => {
46
- const url = new URL("https://127.0.0.1:80")
47
- url.protocol = protocol
48
- url.hostname = hostname
49
- url.port = port
50
- return url.origin
51
- }
52
-
53
- const getExternalIp = () => {
54
- const networkInterfaceMap = networkInterfaces()
55
- let internalIPV4NetworkAddress
56
- Object.keys(networkInterfaceMap).find((key) => {
57
- const networkAddressArray = networkInterfaceMap[key]
58
- return networkAddressArray.find((networkAddress) => {
59
- if (networkAddress.internal) return false
60
- if (!isIpV4(networkAddress)) return false
61
- internalIPV4NetworkAddress = networkAddress
62
- return true
63
- })
64
- })
65
- return internalIPV4NetworkAddress ? internalIPV4NetworkAddress.address : null
66
- }
67
-
68
- const isIpV4 = (networkAddress) => {
69
- // node 18+
70
- if (typeof networkAddress.family === "number") {
71
- return networkAddress.family === 4
72
- }
73
- return networkAddress.family === "IPv4"
74
- }