@jsenv/server 17.6.8 → 17.7.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/dist/jsenv_server.js +60 -25
- package/dist/ws/ws.js +17 -3
- package/docs/handling_requests.md +13 -10
- package/docs/https.md +13 -2
- package/package.json +2 -2
- package/src/router/router.js +1 -1
- package/src/start_server.js +41 -5
package/dist/jsenv_server.js
CHANGED
|
@@ -5899,37 +5899,36 @@ const createResourcePattern = (pattern) => {
|
|
|
5899
5899
|
});
|
|
5900
5900
|
}
|
|
5901
5901
|
|
|
5902
|
-
|
|
5902
|
+
// "/docs/*" also answers the directory itself ("/docs/" and "/docs") with an
|
|
5903
|
+
// empty star, so one route serves a directory and its index. Only a star
|
|
5904
|
+
// widens a pattern: "/docs/" is that one path and "/" the home page.
|
|
5905
|
+
const directoryPattern = pathnamePatternString.endsWith("/*")
|
|
5906
|
+
? PATTERN.create(pathnamePatternString.slice(0, -1), {
|
|
5907
|
+
namedGroupDelimiter: "/",
|
|
5908
|
+
})
|
|
5909
|
+
: null;
|
|
5903
5910
|
// a pattern constraining nothing: every resource matches it, so whoever uses
|
|
5904
5911
|
// it cannot name the resource that matched
|
|
5905
5912
|
const matchesEveryResource =
|
|
5906
5913
|
!searchPattern &&
|
|
5907
5914
|
!hashPattern &&
|
|
5908
|
-
(pathnamePatternString === "*" ||
|
|
5909
|
-
pathnamePatternString === "/" ||
|
|
5910
|
-
pathnamePatternString === "/*");
|
|
5915
|
+
(pathnamePatternString === "*" || pathnamePatternString === "/*");
|
|
5911
5916
|
|
|
5912
5917
|
return {
|
|
5913
5918
|
matchesEveryResource,
|
|
5914
5919
|
match: (resource) => {
|
|
5915
5920
|
const [pathname, search, hash] = resourceToParts(resource);
|
|
5916
5921
|
let decodedPathname = decodeURIComponent(pathname);
|
|
5917
|
-
let result;
|
|
5918
|
-
if (
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
result = {
|
|
5927
|
-
named: {},
|
|
5928
|
-
stars: [decodedPathname.slice(pathnamePatternString.length)],
|
|
5929
|
-
};
|
|
5922
|
+
let result = pathnamePattern.match(decodedPathname);
|
|
5923
|
+
if (!result && directoryPattern) {
|
|
5924
|
+
result = directoryPattern.match(
|
|
5925
|
+
decodedPathname.endsWith("/")
|
|
5926
|
+
? decodedPathname
|
|
5927
|
+
: `${decodedPathname}/`,
|
|
5928
|
+
);
|
|
5929
|
+
if (result) {
|
|
5930
|
+
result.stars = [...(result.stars || []), ""];
|
|
5930
5931
|
}
|
|
5931
|
-
} else {
|
|
5932
|
-
result = pathnamePattern.match(decodedPathname);
|
|
5933
5932
|
}
|
|
5934
5933
|
if (!result) {
|
|
5935
5934
|
return null;
|
|
@@ -6553,7 +6552,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
6553
6552
|
}
|
|
6554
6553
|
if (!route.matchMethod(request.method)) {
|
|
6555
6554
|
// a 405 asserts the resource exists with other methods: a route
|
|
6556
|
-
// matching every resource ("GET *", "GET
|
|
6555
|
+
// matching every resource ("GET *", "GET /*") names no resource, so
|
|
6557
6556
|
// it cannot make that assertion and an unknown address stays a 404
|
|
6558
6557
|
if (!route.isFallback && !route.matchesEveryResource) {
|
|
6559
6558
|
wouldHaveMatched.methodSet.add(route.method);
|
|
@@ -7318,7 +7317,10 @@ const TIMING_NOOP = () => {
|
|
|
7318
7317
|
* - `endpoint` {string} — Required. `"GET /users/:id"`: an http method (or `*` for any)
|
|
7319
7318
|
* and a resource pattern. `:name` captures a segment and `*` a run of segments, both
|
|
7320
7319
|
* land in `request.params`; `?page=:page` captures a search param. `"*"` alone
|
|
7321
|
-
* matches everything.
|
|
7320
|
+
* matches everything. Only `*` widens a pattern: `GET /` is the root alone and
|
|
7321
|
+
* `GET /docs/` that one path, while `GET /docs/*` answers `/docs`, `/docs/` and
|
|
7322
|
+
* everything under it (`request.params[0]` is `""` for the first two): declare the
|
|
7323
|
+
* routes it covers before it. An endpoint ending in `.websocket` marks a websocket route.
|
|
7322
7324
|
* - `fetch` {Function} — Required. `(request, helpers) => response`, async or not.
|
|
7323
7325
|
* Returns a `Response`, a plain `{ status, statusText, statusMessage, headers, body,
|
|
7324
7326
|
* timing }` object (`status` defaults to 404, `statusMessage` feeds the body of 4xx/5xx
|
|
@@ -7358,8 +7360,10 @@ const TIMING_NOOP = () => {
|
|
|
7358
7360
|
* host is answered 403: a page served elsewhere cannot read the responses by rebinding
|
|
7359
7361
|
* its DNS name to this machine. `true` disables the check.
|
|
7360
7362
|
* @param {Object|false} [params.https=false] - `{ certificate, privateKey }` (PEM strings) to serve https.
|
|
7361
|
-
* @param {boolean} [params.redirectHttpToHttps] - With https, answer http requests
|
|
7362
|
-
* to the
|
|
7363
|
+
* @param {boolean|Function} [params.redirectHttpToHttps] - With https, answer http requests
|
|
7364
|
+
* with a 301 to https, on the host the request was sent to. Defaults to true unless
|
|
7365
|
+
* `allowHttpRequestOnHttps` is set. `({ resource }) => boolean` redirects only the requests
|
|
7366
|
+
* it returns true for; the others are served over http.
|
|
7363
7367
|
* @param {boolean} [params.allowHttpRequestOnHttps=false] - With https, also serve plain http
|
|
7364
7368
|
* requests on the same port (`request.origin` tells them apart).
|
|
7365
7369
|
* @param {boolean} [params.http2=false] - Serve http2 (needs `https`, http/1.1 clients are still
|
|
@@ -8053,6 +8057,10 @@ const startServer = async ({
|
|
|
8053
8057
|
};
|
|
8054
8058
|
|
|
8055
8059
|
{
|
|
8060
|
+
const shouldRedirectHttpToHttps =
|
|
8061
|
+
typeof redirectHttpToHttps === "function"
|
|
8062
|
+
? redirectHttpToHttps
|
|
8063
|
+
: () => redirectHttpToHttps;
|
|
8056
8064
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
8057
8065
|
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
8058
8066
|
if (!requestTarget) {
|
|
@@ -8070,9 +8078,17 @@ const startServer = async ({
|
|
|
8070
8078
|
nodeResponse.end("Host not allowed");
|
|
8071
8079
|
return;
|
|
8072
8080
|
}
|
|
8073
|
-
if (
|
|
8081
|
+
if (
|
|
8082
|
+
!nodeRequest.connection.encrypted &&
|
|
8083
|
+
shouldRedirectHttpToHttps(requestTarget)
|
|
8084
|
+
) {
|
|
8074
8085
|
nodeResponse.writeHead(301, {
|
|
8075
|
-
location:
|
|
8086
|
+
location: httpsUrlFromHttpRequest({
|
|
8087
|
+
requestHost,
|
|
8088
|
+
resource: requestTarget.resource,
|
|
8089
|
+
port,
|
|
8090
|
+
serverOrigin,
|
|
8091
|
+
}),
|
|
8076
8092
|
});
|
|
8077
8093
|
nodeResponse.end();
|
|
8078
8094
|
return;
|
|
@@ -8316,6 +8332,25 @@ const createNodeServer = async ({
|
|
|
8316
8332
|
return createServer();
|
|
8317
8333
|
};
|
|
8318
8334
|
|
|
8335
|
+
// http and https share the port (see server_polyglot.js): only the scheme
|
|
8336
|
+
// changes, the host stays the one the client used. The server origin would
|
|
8337
|
+
// send a phone that typed the network ip to its own loopback.
|
|
8338
|
+
const httpsUrlFromHttpRequest = ({
|
|
8339
|
+
requestHost,
|
|
8340
|
+
resource,
|
|
8341
|
+
port,
|
|
8342
|
+
serverOrigin,
|
|
8343
|
+
}) => {
|
|
8344
|
+
const httpsUrlString = `https://${requestHost}`;
|
|
8345
|
+
// no host header (http/1.0), or any host at all with `allowedHosts: true`
|
|
8346
|
+
if (requestHost === undefined || !URL.canParse(httpsUrlString)) {
|
|
8347
|
+
return `${serverOrigin}${resource}`;
|
|
8348
|
+
}
|
|
8349
|
+
const httpsUrl = new URL(httpsUrlString);
|
|
8350
|
+
httpsUrl.port = port;
|
|
8351
|
+
return `${httpsUrl.origin}${resource}`;
|
|
8352
|
+
};
|
|
8353
|
+
|
|
8319
8354
|
const REQUEST_BODY_LINGER_MS = 5_000;
|
|
8320
8355
|
|
|
8321
8356
|
const PROCESS_TEARDOWN_EVENTS_MAP = {
|
package/dist/ws/ws.js
CHANGED
|
@@ -2902,11 +2902,24 @@ function requireWebsocket () {
|
|
|
2902
2902
|
this._redirects = 0;
|
|
2903
2903
|
|
|
2904
2904
|
if (protocols === undefined) {
|
|
2905
|
-
protocols
|
|
2905
|
+
if (!options || options.protocols === undefined) {
|
|
2906
|
+
protocols = [];
|
|
2907
|
+
} else if (Array.isArray(options.protocols)) {
|
|
2908
|
+
protocols = options.protocols;
|
|
2909
|
+
} else {
|
|
2910
|
+
protocols = [options.protocols];
|
|
2911
|
+
}
|
|
2906
2912
|
} else if (!Array.isArray(protocols)) {
|
|
2907
2913
|
if (typeof protocols === 'object' && protocols !== null) {
|
|
2908
2914
|
options = protocols;
|
|
2909
|
-
|
|
2915
|
+
|
|
2916
|
+
if (options.protocols === undefined) {
|
|
2917
|
+
protocols = [];
|
|
2918
|
+
} else if (Array.isArray(options.protocols)) {
|
|
2919
|
+
protocols = options.protocols;
|
|
2920
|
+
} else {
|
|
2921
|
+
protocols = [options.protocols];
|
|
2922
|
+
}
|
|
2910
2923
|
} else {
|
|
2911
2924
|
protocols = [protocols];
|
|
2912
2925
|
}
|
|
@@ -3145,7 +3158,6 @@ function requireWebsocket () {
|
|
|
3145
3158
|
return;
|
|
3146
3159
|
}
|
|
3147
3160
|
|
|
3148
|
-
this._readyState = WebSocket.CLOSING;
|
|
3149
3161
|
this._sender.close(code, data, !this._isServer, (err) => {
|
|
3150
3162
|
//
|
|
3151
3163
|
// This error is handled by the `'error'` listener on the socket. We only
|
|
@@ -3163,6 +3175,7 @@ function requireWebsocket () {
|
|
|
3163
3175
|
}
|
|
3164
3176
|
});
|
|
3165
3177
|
|
|
3178
|
+
this._readyState = WebSocket.CLOSING;
|
|
3166
3179
|
setCloseTimer(this);
|
|
3167
3180
|
}
|
|
3168
3181
|
|
|
@@ -3508,6 +3521,7 @@ function requireWebsocket () {
|
|
|
3508
3521
|
socketPath: undefined,
|
|
3509
3522
|
hostname: undefined,
|
|
3510
3523
|
protocol: undefined,
|
|
3524
|
+
protocols: undefined,
|
|
3511
3525
|
timeout: undefined,
|
|
3512
3526
|
method: 'GET',
|
|
3513
3527
|
host: undefined,
|
|
@@ -19,16 +19,19 @@ await startServer({
|
|
|
19
19
|
|
|
20
20
|
`endpoint` is an http method (or `*`) followed by a resource pattern:
|
|
21
21
|
|
|
22
|
-
| endpoint | matches
|
|
23
|
-
| ---------------------- |
|
|
24
|
-
| `GET /` |
|
|
25
|
-
| `GET /users/:id` | `/users/42`, with `request.params.id === "42"`
|
|
26
|
-
| `GET /
|
|
27
|
-
| `GET /
|
|
28
|
-
|
|
|
29
|
-
| `GET
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
| endpoint | matches |
|
|
23
|
+
| ---------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `GET /` | `/` alone, whatever its search (`/?page=2`): the home page |
|
|
25
|
+
| `GET /users/:id` | `/users/42`, with `request.params.id === "42"` |
|
|
26
|
+
| `GET /docs/` | `/docs/` alone |
|
|
27
|
+
| `GET /docs/*` | `/docs/intro`, with `request.params[0] === "intro"`, and the directory itself: `/docs`, `/docs/` (`""`) |
|
|
28
|
+
| `GET /assets/*` | `/assets/css/main.css`, with `request.params[0] === "css/main.css"` |
|
|
29
|
+
| `GET /search?q=:query` | `/search?q=hello`, with `request.params.query === "hello"` |
|
|
30
|
+
| `* /api/*` | any method |
|
|
31
|
+
| `GET /*` | anything, with the path in `request.params[0]` |
|
|
32
|
+
| `GET *` | anything: a catch-all, put it last |
|
|
33
|
+
|
|
34
|
+
Only `*` widens a pattern. Routes are tried in order, so a route covering others (`GET /docs/*`, `GET *`) goes after them. `fetch` can be async; returning `null` or `undefined` declines the request and the next route is tried.
|
|
32
35
|
|
|
33
36
|
When no route answers, the router builds the response from what almost matched:
|
|
34
37
|
|
package/docs/https.md
CHANGED
|
@@ -20,7 +20,7 @@ const server = await startServer({
|
|
|
20
20
|
server.origin.startsWith("https://"); // true
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Without certificate files, [@jsenv/https-local](https://github.com/jsenv/core/tree/main/packages/tooling/https-local) generates one for localhost, signed by an authority it can install on the machine:
|
|
23
|
+
Without certificate files, [@jsenv/https-local](https://github.com/jsenv/core/tree/main/packages/tooling/https-local) generates one valid for every name of the machine (localhost, its network ips, its name), signed by an authority it can install on the machine:
|
|
24
24
|
|
|
25
25
|
```js
|
|
26
26
|
import { requestCertificate } from "@jsenv/https-local";
|
|
@@ -32,7 +32,18 @@ await startServer({ https: { certificate, privateKey } });
|
|
|
32
32
|
|
|
33
33
|
## Http requests on an https server
|
|
34
34
|
|
|
35
|
-
By default an https server also accepts http on the same port and redirects it (301) to the https origin. `redirectHttpToHttps: false` refuses http instead.
|
|
35
|
+
By default an https server also accepts http on the same port and redirects it (301) to https, on the host the request was sent to: a phone that typed `http://192.168.1.12:3000/app` lands on `https://192.168.1.12:3000/app`. `server.origin` would not do, with `acceptAnyIp` it is the loopback, which for the phone is the phone itself. `redirectHttpToHttps: false` refuses http instead.
|
|
36
|
+
|
|
37
|
+
A function picks what is redirected, the rest is served over http. It receives the request target (`resource` is the path and the search) before the request is read. The case for it is a page that must load before the device trusts the certificate: over https it would show the very warning it is there to remove.
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
await startServer({
|
|
41
|
+
https: { certificate, privateKey },
|
|
42
|
+
redirectHttpToHttps: ({ resource }) => !resource.startsWith("/trust/"),
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To serve both everywhere:
|
|
36
47
|
|
|
37
48
|
```js
|
|
38
49
|
await startServer({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/server",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
|
|
6
6
|
"repository": {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"formidable": "3.5.4",
|
|
60
60
|
"playwright": "1.63.0",
|
|
61
61
|
"undici": "8.11.2",
|
|
62
|
-
"ws": "8.
|
|
62
|
+
"ws": "8.22.0"
|
|
63
63
|
},
|
|
64
64
|
"publishConfig": {
|
|
65
65
|
"access": "public"
|
package/src/router/router.js
CHANGED
|
@@ -356,7 +356,7 @@ It should be should be one of route.${routePropertyName}: ${availableValues.join
|
|
|
356
356
|
}
|
|
357
357
|
if (!route.matchMethod(request.method)) {
|
|
358
358
|
// a 405 asserts the resource exists with other methods: a route
|
|
359
|
-
// matching every resource ("GET *", "GET
|
|
359
|
+
// matching every resource ("GET *", "GET /*") names no resource, so
|
|
360
360
|
// it cannot make that assertion and an unknown address stays a 404
|
|
361
361
|
if (!route.isFallback && !route.matchesEveryResource) {
|
|
362
362
|
wouldHaveMatched.methodSet.add(route.method);
|
package/src/start_server.js
CHANGED
|
@@ -59,7 +59,10 @@ const TIMING_NOOP = () => {
|
|
|
59
59
|
* - `endpoint` {string} — Required. `"GET /users/:id"`: an http method (or `*` for any)
|
|
60
60
|
* and a resource pattern. `:name` captures a segment and `*` a run of segments, both
|
|
61
61
|
* land in `request.params`; `?page=:page` captures a search param. `"*"` alone
|
|
62
|
-
* matches everything.
|
|
62
|
+
* matches everything. Only `*` widens a pattern: `GET /` is the root alone and
|
|
63
|
+
* `GET /docs/` that one path, while `GET /docs/*` answers `/docs`, `/docs/` and
|
|
64
|
+
* everything under it (`request.params[0]` is `""` for the first two): declare the
|
|
65
|
+
* routes it covers before it. An endpoint ending in `.websocket` marks a websocket route.
|
|
63
66
|
* - `fetch` {Function} — Required. `(request, helpers) => response`, async or not.
|
|
64
67
|
* Returns a `Response`, a plain `{ status, statusText, statusMessage, headers, body,
|
|
65
68
|
* timing }` object (`status` defaults to 404, `statusMessage` feeds the body of 4xx/5xx
|
|
@@ -99,8 +102,10 @@ const TIMING_NOOP = () => {
|
|
|
99
102
|
* host is answered 403: a page served elsewhere cannot read the responses by rebinding
|
|
100
103
|
* its DNS name to this machine. `true` disables the check.
|
|
101
104
|
* @param {Object|false} [params.https=false] - `{ certificate, privateKey }` (PEM strings) to serve https.
|
|
102
|
-
* @param {boolean} [params.redirectHttpToHttps] - With https, answer http requests
|
|
103
|
-
* to the
|
|
105
|
+
* @param {boolean|Function} [params.redirectHttpToHttps] - With https, answer http requests
|
|
106
|
+
* with a 301 to https, on the host the request was sent to. Defaults to true unless
|
|
107
|
+
* `allowHttpRequestOnHttps` is set. `({ resource }) => boolean` redirects only the requests
|
|
108
|
+
* it returns true for; the others are served over http.
|
|
104
109
|
* @param {boolean} [params.allowHttpRequestOnHttps=false] - With https, also serve plain http
|
|
105
110
|
* requests on the same port (`request.origin` tells them apart).
|
|
106
111
|
* @param {boolean} [params.http2=false] - Serve http2 (needs `https`, http/1.1 clients are still
|
|
@@ -794,6 +799,10 @@ export const startServer = async ({
|
|
|
794
799
|
};
|
|
795
800
|
|
|
796
801
|
request: {
|
|
802
|
+
const shouldRedirectHttpToHttps =
|
|
803
|
+
typeof redirectHttpToHttps === "function"
|
|
804
|
+
? redirectHttpToHttps
|
|
805
|
+
: () => redirectHttpToHttps;
|
|
797
806
|
const requestEventHandler = async (nodeRequest, nodeResponse) => {
|
|
798
807
|
const requestTarget = readRequestTarget(nodeRequest.url);
|
|
799
808
|
if (!requestTarget) {
|
|
@@ -811,9 +820,17 @@ export const startServer = async ({
|
|
|
811
820
|
nodeResponse.end("Host not allowed");
|
|
812
821
|
return;
|
|
813
822
|
}
|
|
814
|
-
if (
|
|
823
|
+
if (
|
|
824
|
+
!nodeRequest.connection.encrypted &&
|
|
825
|
+
shouldRedirectHttpToHttps(requestTarget)
|
|
826
|
+
) {
|
|
815
827
|
nodeResponse.writeHead(301, {
|
|
816
|
-
location:
|
|
828
|
+
location: httpsUrlFromHttpRequest({
|
|
829
|
+
requestHost,
|
|
830
|
+
resource: requestTarget.resource,
|
|
831
|
+
port,
|
|
832
|
+
serverOrigin,
|
|
833
|
+
}),
|
|
817
834
|
});
|
|
818
835
|
nodeResponse.end();
|
|
819
836
|
return;
|
|
@@ -1062,6 +1079,25 @@ const createNodeServer = async ({
|
|
|
1062
1079
|
return createServer();
|
|
1063
1080
|
};
|
|
1064
1081
|
|
|
1082
|
+
// http and https share the port (see server_polyglot.js): only the scheme
|
|
1083
|
+
// changes, the host stays the one the client used. The server origin would
|
|
1084
|
+
// send a phone that typed the network ip to its own loopback.
|
|
1085
|
+
const httpsUrlFromHttpRequest = ({
|
|
1086
|
+
requestHost,
|
|
1087
|
+
resource,
|
|
1088
|
+
port,
|
|
1089
|
+
serverOrigin,
|
|
1090
|
+
}) => {
|
|
1091
|
+
const httpsUrlString = `https://${requestHost}`;
|
|
1092
|
+
// no host header (http/1.0), or any host at all with `allowedHosts: true`
|
|
1093
|
+
if (requestHost === undefined || !URL.canParse(httpsUrlString)) {
|
|
1094
|
+
return `${serverOrigin}${resource}`;
|
|
1095
|
+
}
|
|
1096
|
+
const httpsUrl = new URL(httpsUrlString);
|
|
1097
|
+
httpsUrl.port = port;
|
|
1098
|
+
return `${httpsUrl.origin}${resource}`;
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1065
1101
|
const REQUEST_BODY_LINGER_MS = 5_000;
|
|
1066
1102
|
|
|
1067
1103
|
const PROCESS_TEARDOWN_EVENTS_MAP = {
|