@jsenv/server 13.0.0 → 14.1.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/README.md CHANGED
@@ -24,34 +24,7 @@ await startServer({
24
24
  })
25
25
  ```
26
26
 
27
- # Example
28
-
29
- _Code starting a server and using [node-fetch](https://github.com/node-fetch/node-fetch) to send a request on that server:_
30
-
31
- ```js
32
- import fetch from "node-fetch"
33
- import { startServer } from "@jsenv/server"
34
-
35
- const server = await startServer({
36
- services: [
37
- {
38
- handleRequest: () => {
39
- return {
40
- status: 200,
41
- headers: {
42
- "content-type": "text/plain",
43
- },
44
- body: "Hello world",
45
- }
46
- },
47
- },
48
- ],
49
- })
50
-
51
- const response = await fetch(server.origin)
52
- const responseBodyAsText = await response.text()
53
- console.log(responseBodyAsText) // "Hello world"
54
- ```
27
+ # Examples
55
28
 
56
29
  _Code starting a server with 2 request handlers:_
57
30
 
@@ -70,10 +43,10 @@ const server = await startServer({
70
43
  {
71
44
  name: "index",
72
45
  handleRequest: (request) => {
73
- if (request.ressource === "/") {
46
+ if (request.resource === "/") {
74
47
  return { status: 200 }
75
48
  }
76
- return null // means "I don't handle that request"
49
+ return null
77
50
  },
78
51
  },
79
52
  {
@@ -101,8 +74,8 @@ import { startServer } from "@jsenv/server"
101
74
 
102
75
  await startServer({
103
76
  protocol: "https",
104
- certificate: readFileSyncAsString("./server.crt"),
105
- privateKey: readFileSyncAsString("./server.key"),
77
+ certificate: readFileSync(new URL("./server.crt", import.meta.url), "utf8"),
78
+ privateKey: readFileSync(new URL("./server.key", import.meta.url), "utf8"),
106
79
  allowHttpRequestOnHttps: true,
107
80
  services: [
108
81
  {
@@ -120,11 +93,6 @@ await startServer({
120
93
  },
121
94
  ],
122
95
  })
123
-
124
- function readFileSyncAsString(relativeUrl) {
125
- const fileUrl = new URL(relativeUrl, import.meta.url)
126
- return String(readFileSync(fileUrl))
127
- }
128
96
  ```
129
97
 
130
98
  _Code starting a server for static files:_
@@ -136,10 +104,8 @@ await startServer({
136
104
  services: [
137
105
  {
138
106
  handleRequest: async (request) => {
139
- const fileUrl = new URL(request.ressource.slice(1), import.meta.url)
140
- const response = await fetchFileSystem(fileUrl, {
141
- ...request,
142
- })
107
+ const fileUrl = new URL(request.resource.slice(1), import.meta.url)
108
+ const response = await fetchFileSystem(fileUrl, request)
143
109
  return response
144
110
  },
145
111
  },
@@ -149,16 +115,17 @@ await startServer({
149
115
 
150
116
  # Documentation
151
117
 
152
- - [Handling requests](./docs/handling_requests/handling_requests.md)
153
- - [Handling errors](./docs/handling_errors/handling_errors.md)
154
- - [Server timing](./docs/server_timing/server_timing.md)
155
- - [CORS](./docs/cors/cors.md)
156
- - [https](./docs/https/https.md)
157
- - [Serving files](./docs/serving_files/serving_files.md)
158
- - [Content negotiation](./docs/content_negotiation/content_negotiation.md)
159
- - [Server Sent Events](./docs/sse/sse.md)
160
- - [Cluster](./docs/cluster/cluster.md)
161
- - [Http2 push](./docs/http2_push/http2_push.md)
118
+ - [Handling requests](./docs/handling_requests.md)
119
+ - [Handling errors](./docs/handling_errors.md)
120
+ - [Server timing](./docs/server_timing.md)
121
+ - [CORS](./docs/cors.md)
122
+ - [https](./docs/https.md)
123
+ - [Serving files](./docs/serving_files.md)
124
+ - [Content negotiation](./docs/content_negotiation.md)
125
+ - [Websocket](./docs/websocket.md)
126
+ - [Server Sent Events](./docs/server_sent_events.md)
127
+ - [Cluster](./docs/cluster.md)
128
+ - [Http2 push](./docs/http2_push.md)
162
129
 
163
130
  # Installation
164
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "13.0.0",
3
+ "version": "14.1.0",
4
4
  "description": "Write your Node.js server using pure functions",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -37,7 +37,8 @@
37
37
  "@jsenv/abort": "4.2.3",
38
38
  "@jsenv/log": "3.1.0",
39
39
  "@jsenv/url-meta": "7.0.0",
40
- "@jsenv/utils": "2.0.1"
40
+ "@jsenv/utils": "2.0.1",
41
+ "ws": "8.8.1"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@jsenv/fetch": "../fetch/",
@@ -64,7 +64,7 @@ export const fetchFileSystem = async (
64
64
  }
65
65
 
66
66
  // here you might be tempted to add || cacheControl === 'no-cache'
67
- // but no-cache means ressource can be cache but must be revalidated (yeah naming is strange)
67
+ // but no-cache means resource can be cache but must be revalidated (yeah naming is strange)
68
68
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Cacheability
69
69
  if (cacheControl === "no-store") {
70
70
  if (etagEnabled) {
@@ -206,7 +206,7 @@ const getClientCacheResponse = async ({
206
206
  sourceUrl,
207
207
  }) => {
208
208
  // here you might be tempted to add || headers["cache-control"] === "no-cache"
209
- // but no-cache means ressource can be cache but must be revalidated (yeah naming is strange)
209
+ // but no-cache means resource can be cache but must be revalidated (yeah naming is strange)
210
210
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Cacheability
211
211
 
212
212
  if (
@@ -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
 
@@ -38,7 +38,7 @@ export const nodeStreamToObservable = (nodeStream) => {
38
38
  // used in the next ${readableStreamLifetimeInSeconds} otherwise destroys it
39
39
  const timeout = setTimeout(() => {
40
40
  process.emitWarning(
41
- `Readable stream not used after ${readableStreamLifetimeInSeconds} seconds. It will be destroyed to release ressources`,
41
+ `Readable stream not used after ${readableStreamLifetimeInSeconds} seconds. It will be destroyed to release resources`,
42
42
  {
43
43
  CODE: "READABLE_STREAM_TIMEOUT",
44
44
  // url is for http client request
@@ -6,7 +6,9 @@ export const fromNodeRequest = (nodeRequest, { serverOrigin, signal }) => {
6
6
  const body = nodeStreamToObservable(nodeRequest)
7
7
 
8
8
  let requestOrigin
9
- if (nodeRequest.authority) {
9
+ if (nodeRequest.upgrade) {
10
+ requestOrigin = serverOrigin
11
+ } else if (nodeRequest.authority) {
10
12
  requestOrigin = nodeRequest.connection.encrypted
11
13
  ? `https://${nodeRequest.authority}`
12
14
  : `http://${nodeRequest.authority}`
@@ -22,8 +24,8 @@ export const fromNodeRequest = (nodeRequest, { serverOrigin, signal }) => {
22
24
  signal,
23
25
  http2: Boolean(nodeRequest.stream),
24
26
  origin: requestOrigin,
25
- ...getPropertiesFromRessource({
26
- ressource: nodeRequest.url,
27
+ ...getPropertiesFromResource({
28
+ resource: nodeRequest.url,
27
29
  baseUrl: requestOrigin,
28
30
  }),
29
31
  method: nodeRequest.method,
@@ -34,13 +36,13 @@ export const fromNodeRequest = (nodeRequest, { serverOrigin, signal }) => {
34
36
 
35
37
  export const applyRedirectionToRequest = (
36
38
  request,
37
- { ressource, pathname, ...rest },
39
+ { resource, pathname, ...rest },
38
40
  ) => {
39
41
  return {
40
42
  ...request,
41
- ...(ressource
42
- ? getPropertiesFromRessource({
43
- ressource,
43
+ ...(resource
44
+ ? getPropertiesFromResource({
45
+ resource,
44
46
  baseUrl: request.url,
45
47
  })
46
48
  : pathname
@@ -53,20 +55,20 @@ export const applyRedirectionToRequest = (
53
55
  }
54
56
  }
55
57
 
56
- const getPropertiesFromRessource = ({ ressource, baseUrl }) => {
57
- const urlObject = new URL(ressource, baseUrl)
58
+ const getPropertiesFromResource = ({ resource, baseUrl }) => {
59
+ const urlObject = new URL(resource, baseUrl)
58
60
  let pathname = urlObject.pathname
59
61
 
60
62
  return {
61
63
  url: String(urlObject),
62
64
  pathname,
63
- ressource,
65
+ resource,
64
66
  }
65
67
  }
66
68
 
67
69
  const getPropertiesFromPathname = ({ pathname, baseUrl }) => {
68
- return getPropertiesFromRessource({
69
- ressource: `${pathname}${new URL(baseUrl).search}`,
70
+ return getPropertiesFromResource({
71
+ resource: `${pathname}${new URL(baseUrl).search}`,
70
72
  baseUrl,
71
73
  })
72
74
  }
@@ -93,12 +95,12 @@ export const createPushRequest = (request, { signal, pathname, method }) => {
93
95
  const getHeadersInheritedByPushRequest = (request) => {
94
96
  const headersInherited = { ...request.headers }
95
97
  // mtime sent by the client in request headers concerns the main request
96
- // Time remains valid for request to other ressources so we keep it
98
+ // Time remains valid for request to other resources so we keep it
97
99
  // in child requests
98
100
  // delete childHeaders["if-modified-since"]
99
101
 
100
102
  // eTag sent by the client in request headers concerns the main request
101
- // A request made to an other ressource must not inherit the eTag
103
+ // A request made to an other resource must not inherit the eTag
102
104
  delete headersInherited["if-none-match"]
103
105
 
104
106
  return headersInherited
@@ -62,6 +62,7 @@ export const createPolyglotServer = async ({
62
62
 
63
63
  // The async part is just to lazyly import "http2" or "https"
64
64
  // so that these module are parsed only if used.
65
+ // https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions
65
66
  const createSecureServer = async ({
66
67
  certificate,
67
68
  privateKey,
@@ -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)
package/src/main.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Ideally this file should be kept simple to help discovering codebase progressively.
5
5
  */
6
6
 
7
- export { startServer } from "./server.js"
7
+ export { startServer } from "./start_server.js"
8
8
  export { setupRoutes } from "./service_composition/routing.js"
9
9
  export { readRequestBody } from "./readRequestBody.js"
10
10
  export { fetchFileSystem } from "./fetch_filesystem.js"
@@ -42,5 +42,5 @@ export { jsenvServiceResponseAcceptanceCheck } from "./services/response_accepta
42
42
  export { serveDirectory } from "./serve_directory.js"
43
43
  export { fromFetchResponse } from "./from_fetch_response.js"
44
44
  export { composeTwoResponses } from "./internal/response_composition.js"
45
- export { jsenvServiceRessourceAliases } from "./services/ressource_aliases/jsenv_service_ressource_aliases.js"
45
+ export { jsenvServiceRequestAliases } from "./services/request_aliases/jsenv_service_request_aliases.js"
46
46
  export { findFreePort } from "./internal/listen.js"
@@ -4,6 +4,7 @@ const HOOK_NAMES = [
4
4
  "serverListening",
5
5
  "redirectRequest",
6
6
  "handleRequest",
7
+ "handleWebsocket",
7
8
  "handleError",
8
9
  "onResponsePush",
9
10
  "injectResponseHeaders",
@@ -0,0 +1,37 @@
1
+ import { URL_META } from "@jsenv/url-meta"
2
+
3
+ export const jsenvServiceRequestAliases = (resourceAliases) => {
4
+ const aliases = {}
5
+ Object.keys(resourceAliases).forEach((key) => {
6
+ aliases[asFileUrl(key)] = asFileUrl(resourceAliases[key])
7
+ })
8
+ return {
9
+ name: "jsenv:request_aliases",
10
+ redirectRequest: (request) => {
11
+ const resourceBeforeAlias = request.resource
12
+ const urlAfterAliasing = URL_META.applyAliases({
13
+ url: asFileUrl(request.pathname),
14
+ aliases,
15
+ })
16
+ const resourceAfterAlias = urlAfterAliasing.slice("file://".length)
17
+ if (resourceBeforeAlias === resourceAfterAlias) {
18
+ return null
19
+ }
20
+ const resource = replaceResource(resourceBeforeAlias, resourceAfterAlias)
21
+ return { resource }
22
+ },
23
+ }
24
+ }
25
+
26
+ const asFileUrl = (specifier) => new URL(specifier, "file:///").href
27
+
28
+ const replaceResource = (resourceBeforeAlias, newValue) => {
29
+ const urlObject = new URL(resourceBeforeAlias, "file://")
30
+ const searchSeparatorIndex = newValue.indexOf("?")
31
+ if (searchSeparatorIndex > -1) {
32
+ return newValue // let new value override search params
33
+ }
34
+ urlObject.pathname = newValue
35
+ const resource = `${urlObject.pathname}${urlObject.search}`
36
+ return resource
37
+ }
@@ -37,9 +37,7 @@ export const createSSERoom = ({
37
37
 
38
38
  const lastKnownId =
39
39
  request.headers["last-event-id"] ||
40
- new URL(request.ressource, request.origin).searchParams.get(
41
- "last-event-id",
42
- )
40
+ new URL(request.url).searchParams.get("last-event-id")
43
41
 
44
42
  if (clients.size >= maxClientAllowed) {
45
43
  return {