@jsenv/core 41.5.35 → 41.5.37
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/{page_picker.js → client/client_monitoring/clients_page.html} +650 -2
- package/dist/client/https_trust/https_trust.html +362 -0
- package/dist/js/page_switcher.js +809 -1
- package/dist/start_dev_server/start_dev_server.js +126 -4
- package/package.json +2 -2
- package/src/dev/dev_server_plugins/client/https_trust.html +352 -0
- package/src/dev/dev_server_plugins/dev_server_plugin_https_trust.js +93 -0
- package/src/dev/start_dev_server.js +35 -1
- package/dist/html/clients_page.html +0 -651
- /package/dist/{html → client/client_monitoring}/client_monitor_page.html +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WebSocketResponse, pickContentType, ServerEvents, serverPluginErrorHandler, fetchDirectory, composeTwoResponses, serverPluginCORS, jsenvAccessControlAllowedHeaders, startServer } from "@jsenv/server";
|
|
2
|
+
import { randomUUID, X509Certificate } from "node:crypto";
|
|
2
3
|
import { existsSync, statSync, readFileSync, realpathSync, readdirSync, lstatSync } from "node:fs";
|
|
3
4
|
import { pathToFileURL } from "node:url";
|
|
4
5
|
import { urlToRelativeUrl, registerFileLifecycle, lookupPackageDirectory, readPackageAtOrNull, generateContentFrame, errorToHTML, URL_META, asUrlWithoutSearch, DATA_URL, CONTENT_TYPE, normalizeImportMap, composeTwoImportMaps, resolveImport, createDetailedMessage, UNICODE, JS_QUOTES, urlToExtension, urlToBasename, applyNodeEsmResolution, readCustomConditionsFromProcessArgs, urlIsOrIsInsideOf, collectFiles, registerDirectoryLifecycle, readEntryStatSync, applyFileSystemMagicResolution, getExtensionsToTry, urlToFilename, ensurePathnameTrailingSlash, compareFileUrls, setUrlExtension, stringifyUrlSite, injectQueryParamsIntoSpecifier, isSpecifierForNodeBuiltin, injectQueryParams, urlToFileSystemPath, writeFileSync, moveUrl, ensureWindowsDriveLetter, validateResponseIntegrity, setUrlFilename, getCallerPosition, asSpecifierWithoutSearch, bufferToEtag, isFileSystemPath, urlToPathname, setUrlBasename, createLogger, normalizeUrl, ANSI, RUNTIME_COMPAT, formatError, assertAndNormalizeDirectoryUrl, browserDefaultRuntimeCompat, inferRuntimeCompatFromClosestPackage, createTaskLog } from "./jsenv_core_packages.js";
|
|
@@ -8,7 +9,6 @@ import { createMagicSource, composeTwoSourcemaps, generateSourcemapFileUrl, gene
|
|
|
8
9
|
import { jsenvPluginSupervisor } from "@jsenv/plugin-supervisor";
|
|
9
10
|
import { jsenvPluginTranspilation } from "@jsenv/plugin-transpilation";
|
|
10
11
|
import { bundleJsModules } from "@jsenv/plugin-bundling";
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { convertFileSystemErrorToResponseProperties } from "@jsenv/server/src/plugins/filesystem/filesystem_error_to_response.js";
|
|
13
13
|
import "./jsenv_core_node_modules.js";
|
|
14
14
|
import "node:process";
|
|
@@ -986,11 +986,11 @@ const clientReporterFileUrl = new URL(
|
|
|
986
986
|
import.meta.url,
|
|
987
987
|
).href;
|
|
988
988
|
const clientsPageFileUrl = new URL(
|
|
989
|
-
"../
|
|
989
|
+
"../client/client_monitoring/clients_page.html",
|
|
990
990
|
import.meta.url,
|
|
991
991
|
).href;
|
|
992
992
|
const clientMonitorPageFileUrl = new URL(
|
|
993
|
-
"../
|
|
993
|
+
"../client/client_monitoring/client_monitor_page.html",
|
|
994
994
|
import.meta.url,
|
|
995
995
|
).href;
|
|
996
996
|
|
|
@@ -8602,6 +8602,99 @@ const devServerPluginChromeDevToolsJson = ({ sourceDirectoryUrl }) => {
|
|
|
8602
8602
|
};
|
|
8603
8603
|
};
|
|
8604
8604
|
|
|
8605
|
+
/*
|
|
8606
|
+
* Lets another device (a phone) trust the root certificate that signs the dev
|
|
8607
|
+
* server certificate, so it opens the dev server over https without a warning.
|
|
8608
|
+
*
|
|
8609
|
+
* Everything under HTTPS_TRUST_PATHNAME is served over plain http too (see the
|
|
8610
|
+
* redirectHttpToHttps passed by startDevServer): it is the one page that must
|
|
8611
|
+
* load before the certificate is trusted, over https it would show the very
|
|
8612
|
+
* warning it exists to remove. Only the certificate is served, never its key.
|
|
8613
|
+
*
|
|
8614
|
+
* The page is sent as written, not cooked like the project pages: it needs
|
|
8615
|
+
* nothing cooking injects, and a project plugin rewriting html must not be able
|
|
8616
|
+
* to break the one page reached before trust.
|
|
8617
|
+
*
|
|
8618
|
+
* What it means for the device, and why the fingerprint must be compared with
|
|
8619
|
+
* the terminal: docs/users/b_dev/b_dev.md, "Trusting it on a phone".
|
|
8620
|
+
*/
|
|
8621
|
+
|
|
8622
|
+
|
|
8623
|
+
const HTTPS_TRUST_PATHNAME = "/.internal/https/";
|
|
8624
|
+
|
|
8625
|
+
const httpsTrustPageFileUrl = new URL(
|
|
8626
|
+
"../client/https_trust/https_trust.html",
|
|
8627
|
+
import.meta.url,
|
|
8628
|
+
);
|
|
8629
|
+
|
|
8630
|
+
// rootCertificate is a node:crypto X509Certificate
|
|
8631
|
+
const devServerPluginHttpsTrust = ({ rootCertificate }) => {
|
|
8632
|
+
const commonName = readCommonName(rootCertificate.subject);
|
|
8633
|
+
const rootCertificateInfo = {
|
|
8634
|
+
fingerprint256: rootCertificate.fingerprint256,
|
|
8635
|
+
commonName,
|
|
8636
|
+
};
|
|
8637
|
+
// "https local root certificate" -> "https_local_root_certificate.crt"
|
|
8638
|
+
const rootCertificateFilename = `${commonName.replace(/[^\w.-]+/g, "_")}.crt`;
|
|
8639
|
+
|
|
8640
|
+
return {
|
|
8641
|
+
name: "jsenv:https_trust",
|
|
8642
|
+
routes: [
|
|
8643
|
+
{
|
|
8644
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}`,
|
|
8645
|
+
description:
|
|
8646
|
+
"Page to trust the dev server https on another device (a phone). Served over http.",
|
|
8647
|
+
declarationSource: import.meta.url,
|
|
8648
|
+
fetch: () => {
|
|
8649
|
+
return new Response(readFileSync(httpsTrustPageFileUrl), {
|
|
8650
|
+
headers: { "content-type": "text/html" },
|
|
8651
|
+
});
|
|
8652
|
+
},
|
|
8653
|
+
},
|
|
8654
|
+
{
|
|
8655
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}root.crt`,
|
|
8656
|
+
description: "The root certificate signing the dev server certificate.",
|
|
8657
|
+
declarationSource: import.meta.url,
|
|
8658
|
+
fetch: () => {
|
|
8659
|
+
return new Response(rootCertificate.toString(), {
|
|
8660
|
+
headers: {
|
|
8661
|
+
"content-type": "application/x-x509-ca-cert",
|
|
8662
|
+
"content-disposition": `attachment; filename="${rootCertificateFilename}"`,
|
|
8663
|
+
},
|
|
8664
|
+
});
|
|
8665
|
+
},
|
|
8666
|
+
},
|
|
8667
|
+
{
|
|
8668
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}root.json`,
|
|
8669
|
+
description: "Fingerprint and name of the root certificate.",
|
|
8670
|
+
declarationSource: import.meta.url,
|
|
8671
|
+
fetch: () => {
|
|
8672
|
+
return Response.json(rootCertificateInfo);
|
|
8673
|
+
},
|
|
8674
|
+
},
|
|
8675
|
+
{
|
|
8676
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}ping`,
|
|
8677
|
+
description:
|
|
8678
|
+
"Fetched over https by the page: it fails as long as the device does not trust the certificate.",
|
|
8679
|
+
declarationSource: import.meta.url,
|
|
8680
|
+
fetch: () => {
|
|
8681
|
+
return new Response(null, { status: 204 });
|
|
8682
|
+
},
|
|
8683
|
+
},
|
|
8684
|
+
],
|
|
8685
|
+
};
|
|
8686
|
+
};
|
|
8687
|
+
|
|
8688
|
+
// "CN=https local root certificate\nO=..." -> "https local root certificate"
|
|
8689
|
+
const readCommonName = (subject) => {
|
|
8690
|
+
for (const line of subject.split("\n")) {
|
|
8691
|
+
if (line.startsWith("CN=")) {
|
|
8692
|
+
return line.slice("CN=".length);
|
|
8693
|
+
}
|
|
8694
|
+
}
|
|
8695
|
+
return subject;
|
|
8696
|
+
};
|
|
8697
|
+
|
|
8605
8698
|
const devServerPluginInjectServerResponseHeader = ({
|
|
8606
8699
|
sourceDirectoryUrl,
|
|
8607
8700
|
}) => {
|
|
@@ -12527,7 +12620,7 @@ const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
|
|
|
12527
12620
|
* @param {number} [params.port=3456] - Port to listen on (0 = a free port).
|
|
12528
12621
|
* @param {string} [params.hostname] - Hostname to bind to.
|
|
12529
12622
|
* @param {boolean} [params.acceptAnyIp=false] - Also accept connections on the machine's IPs (so other devices on the network — a phone — can reach the server). Off by default: exposing the dev server beyond localhost is an explicit choice, not something a dev tool decides.
|
|
12530
|
-
* @param {
|
|
12623
|
+
* @param {object} [params.https] - HTTPS as `{ certificate, privateKey }` (PEM strings). With `rootCertificate` (the PEM of the authority that signed `certificate`) and `acceptAnyIp`, the dev server also serves `http://<ip>:<port>/.internal/https/`, a page to trust that authority on a phone (see docs/users/b_dev/b_dev.md, "Trusting it on a phone").
|
|
12531
12624
|
* @param {boolean} [params.http2=false] - HTTP/2 (requires https).
|
|
12532
12625
|
* @param {Array} [params.plugins=[]] - jsenv plugins (transformUrlContent, serverRoutes, serverEvents, effect, …).
|
|
12533
12626
|
* @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
|
|
@@ -12659,6 +12752,7 @@ const startDevServer = async ({
|
|
|
12659
12752
|
);
|
|
12660
12753
|
}
|
|
12661
12754
|
}
|
|
12755
|
+
let httpsTrust = null;
|
|
12662
12756
|
// params normalization
|
|
12663
12757
|
{
|
|
12664
12758
|
if (runtimeCompat === undefined) {
|
|
@@ -12673,6 +12767,16 @@ const startDevServer = async ({
|
|
|
12673
12767
|
if (clientAutoreload === false) {
|
|
12674
12768
|
clientAutoreload = { enabled: false };
|
|
12675
12769
|
}
|
|
12770
|
+
if (https) {
|
|
12771
|
+
const { certificate, privateKey, rootCertificate } = https;
|
|
12772
|
+
https = { certificate, privateKey };
|
|
12773
|
+
// a localhost-only server has no phone to serve
|
|
12774
|
+
if (rootCertificate && acceptAnyIp) {
|
|
12775
|
+
httpsTrust = {
|
|
12776
|
+
rootCertificate: new X509Certificate(rootCertificate),
|
|
12777
|
+
};
|
|
12778
|
+
}
|
|
12779
|
+
}
|
|
12676
12780
|
}
|
|
12677
12781
|
|
|
12678
12782
|
const logger = createLogger({ logLevel });
|
|
@@ -12830,6 +12934,7 @@ const startDevServer = async ({
|
|
|
12830
12934
|
}),
|
|
12831
12935
|
// chrome devtools
|
|
12832
12936
|
devServerPluginChromeDevToolsJson({ sourceDirectoryUrl }),
|
|
12937
|
+
...(httpsTrust ? [devServerPluginHttpsTrust(httpsTrust)] : []),
|
|
12833
12938
|
...serverPlugins,
|
|
12834
12939
|
devServerPluginServeSourceFiles({
|
|
12835
12940
|
packageDirectory,
|
|
@@ -12868,6 +12973,12 @@ const startDevServer = async ({
|
|
|
12868
12973
|
startLog: false,
|
|
12869
12974
|
|
|
12870
12975
|
https,
|
|
12976
|
+
...(httpsTrust
|
|
12977
|
+
? {
|
|
12978
|
+
redirectHttpToHttps: ({ resource }) =>
|
|
12979
|
+
!resource.startsWith(HTTPS_TRUST_PATHNAME),
|
|
12980
|
+
}
|
|
12981
|
+
: {}),
|
|
12871
12982
|
http2,
|
|
12872
12983
|
acceptAnyIp,
|
|
12873
12984
|
hostname,
|
|
@@ -12893,6 +13004,17 @@ const startDevServer = async ({
|
|
|
12893
13004
|
logger.info(``);
|
|
12894
13005
|
Object.keys(server.origins).forEach((key) => {
|
|
12895
13006
|
logger.info(`- ${server.origins[key]}`);
|
|
13007
|
+
if (httpsTrust && key === "externalip") {
|
|
13008
|
+
const externalHostname = new URL(server.origins.externalip).hostname;
|
|
13009
|
+
const httpsTrustPageUrl = new URL(
|
|
13010
|
+
HTTPS_TRUST_PATHNAME,
|
|
13011
|
+
`http://${externalHostname}:${server.port}`,
|
|
13012
|
+
);
|
|
13013
|
+
logger.info(` trust it on a phone: ${httpsTrustPageUrl}`);
|
|
13014
|
+
logger.info(
|
|
13015
|
+
` root certificate SHA-256: ${httpsTrust.rootCertificate.fingerprint256}`,
|
|
13016
|
+
);
|
|
13017
|
+
}
|
|
12896
13018
|
});
|
|
12897
13019
|
logger.info(``);
|
|
12898
13020
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/core",
|
|
3
|
-
"version": "41.5.
|
|
3
|
+
"version": "41.5.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tool to develop, test and build js projects",
|
|
6
6
|
"repository": {
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@jsenv/plugin-minification": "1.7.27",
|
|
81
81
|
"@jsenv/plugin-supervisor": "1.8.29",
|
|
82
82
|
"@jsenv/plugin-transpilation": "1.7.14",
|
|
83
|
-
"@jsenv/server": "17.6.
|
|
83
|
+
"@jsenv/server": "17.6.9",
|
|
84
84
|
"@jsenv/sourcemap": "1.4.13",
|
|
85
85
|
"react-table": "7.8.0"
|
|
86
86
|
},
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Trust the dev server https</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
--ink: #111827;
|
|
11
|
+
--muted: #6b7280;
|
|
12
|
+
--paper: #f3f4f6;
|
|
13
|
+
--card: #ffffff;
|
|
14
|
+
--line: #e5e7eb;
|
|
15
|
+
--accent: #2563eb;
|
|
16
|
+
--accent-ink: #ffffff;
|
|
17
|
+
--ok: #15803d;
|
|
18
|
+
--ok-paper: #dcfce7;
|
|
19
|
+
--warn: #b45309;
|
|
20
|
+
--warn-paper: #fef3c7;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--ink: #e5e7eb;
|
|
25
|
+
--muted: #9ca3af;
|
|
26
|
+
--paper: #0b1020;
|
|
27
|
+
--card: #111827;
|
|
28
|
+
--line: #1f2937;
|
|
29
|
+
--accent: #3b82f6;
|
|
30
|
+
--ok: #4ade80;
|
|
31
|
+
--ok-paper: #052e16;
|
|
32
|
+
--warn: #fbbf24;
|
|
33
|
+
--warn-paper: #2d1a02;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
body {
|
|
37
|
+
margin: 0;
|
|
38
|
+
padding: 16px;
|
|
39
|
+
color: var(--ink);
|
|
40
|
+
font:
|
|
41
|
+
15px/1.5 -apple-system,
|
|
42
|
+
BlinkMacSystemFont,
|
|
43
|
+
"Segoe UI",
|
|
44
|
+
Roboto,
|
|
45
|
+
sans-serif;
|
|
46
|
+
background: var(--paper);
|
|
47
|
+
}
|
|
48
|
+
main {
|
|
49
|
+
display: flex;
|
|
50
|
+
max-width: 34rem;
|
|
51
|
+
margin: 0 auto;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: 16px;
|
|
54
|
+
}
|
|
55
|
+
h1 {
|
|
56
|
+
margin: 8px 0 0;
|
|
57
|
+
font-size: 1.35rem;
|
|
58
|
+
}
|
|
59
|
+
h2 {
|
|
60
|
+
margin: 0;
|
|
61
|
+
font-size: 1rem;
|
|
62
|
+
}
|
|
63
|
+
p {
|
|
64
|
+
margin: 0;
|
|
65
|
+
}
|
|
66
|
+
ol {
|
|
67
|
+
margin: 0;
|
|
68
|
+
padding-left: 1.3em;
|
|
69
|
+
}
|
|
70
|
+
li + li {
|
|
71
|
+
margin-top: 6px;
|
|
72
|
+
}
|
|
73
|
+
code {
|
|
74
|
+
font:
|
|
75
|
+
0.85em ui-monospace,
|
|
76
|
+
SFMono-Regular,
|
|
77
|
+
Menlo,
|
|
78
|
+
monospace;
|
|
79
|
+
}
|
|
80
|
+
.muted {
|
|
81
|
+
color: var(--muted);
|
|
82
|
+
}
|
|
83
|
+
.card {
|
|
84
|
+
display: flex;
|
|
85
|
+
padding: 14px 16px;
|
|
86
|
+
flex-direction: column;
|
|
87
|
+
gap: 10px;
|
|
88
|
+
background: var(--card);
|
|
89
|
+
border: 1px solid var(--line);
|
|
90
|
+
border-radius: 10px;
|
|
91
|
+
}
|
|
92
|
+
.button {
|
|
93
|
+
display: block;
|
|
94
|
+
padding: 12px 16px;
|
|
95
|
+
color: var(--accent);
|
|
96
|
+
font-weight: 600;
|
|
97
|
+
text-align: center;
|
|
98
|
+
text-decoration: none;
|
|
99
|
+
border: 1px solid var(--accent);
|
|
100
|
+
border-radius: 8px;
|
|
101
|
+
}
|
|
102
|
+
.button[data-primary] {
|
|
103
|
+
color: var(--accent-ink);
|
|
104
|
+
background: var(--accent);
|
|
105
|
+
}
|
|
106
|
+
#status {
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
}
|
|
109
|
+
#status_card[data-state="trusted"] {
|
|
110
|
+
background: var(--ok-paper);
|
|
111
|
+
border-color: var(--ok);
|
|
112
|
+
}
|
|
113
|
+
#status_card[data-state="trusted"] #status {
|
|
114
|
+
color: var(--ok);
|
|
115
|
+
}
|
|
116
|
+
#status_card[data-state="untrusted"] {
|
|
117
|
+
background: var(--warn-paper);
|
|
118
|
+
border-color: var(--warn);
|
|
119
|
+
}
|
|
120
|
+
#status_card[data-state="untrusted"] #status {
|
|
121
|
+
color: var(--warn);
|
|
122
|
+
}
|
|
123
|
+
#fingerprint {
|
|
124
|
+
display: block;
|
|
125
|
+
word-break: break-all;
|
|
126
|
+
}
|
|
127
|
+
details {
|
|
128
|
+
padding: 14px 16px;
|
|
129
|
+
background: var(--card);
|
|
130
|
+
border: 1px solid var(--line);
|
|
131
|
+
border-radius: 10px;
|
|
132
|
+
}
|
|
133
|
+
summary {
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
}
|
|
137
|
+
details[open] summary {
|
|
138
|
+
margin-bottom: 10px;
|
|
139
|
+
}
|
|
140
|
+
details .this_device {
|
|
141
|
+
display: none;
|
|
142
|
+
color: var(--muted);
|
|
143
|
+
font-weight: 400;
|
|
144
|
+
}
|
|
145
|
+
details[data-current] .this_device {
|
|
146
|
+
display: inline;
|
|
147
|
+
}
|
|
148
|
+
details p + ol,
|
|
149
|
+
details ol + p,
|
|
150
|
+
details p + p {
|
|
151
|
+
margin-top: 10px;
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
<main>
|
|
157
|
+
<h1>Trust the dev server https</h1>
|
|
158
|
+
<p class="muted">
|
|
159
|
+
Install the root certificate of the computer running the dev server and
|
|
160
|
+
this device opens it over https without a warning. Service workers,
|
|
161
|
+
push, install and the other secure-context APIs need it.
|
|
162
|
+
</p>
|
|
163
|
+
|
|
164
|
+
<div id="status_card" class="card" data-state="checking">
|
|
165
|
+
<p id="status">Checking whether this device trusts the dev server…</p>
|
|
166
|
+
<a id="continue" class="button" jsenv-ignore href="/">
|
|
167
|
+
Continue to https
|
|
168
|
+
</a>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="card">
|
|
172
|
+
<a
|
|
173
|
+
id="download"
|
|
174
|
+
class="button"
|
|
175
|
+
jsenv-ignore
|
|
176
|
+
href="./root.crt"
|
|
177
|
+
data-primary
|
|
178
|
+
>
|
|
179
|
+
Download the root certificate
|
|
180
|
+
</a>
|
|
181
|
+
<p>
|
|
182
|
+
<span class="muted">SHA-256 fingerprint</span>
|
|
183
|
+
<code id="fingerprint">…</code>
|
|
184
|
+
</p>
|
|
185
|
+
<p class="muted">
|
|
186
|
+
Once installed, the fingerprint the device shows must match the one
|
|
187
|
+
the dev server prints in its terminal: this page travels over plain
|
|
188
|
+
http, so the network could swap the file.
|
|
189
|
+
</p>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div id="platforms">
|
|
193
|
+
<details data-platform="android" open>
|
|
194
|
+
<summary>
|
|
195
|
+
Android <span class="this_device">(this device)</span>
|
|
196
|
+
</summary>
|
|
197
|
+
<ol>
|
|
198
|
+
<li>
|
|
199
|
+
Tap <b>Download the root certificate</b>. Opening the file only
|
|
200
|
+
says CA certificates must be installed from Settings: that is
|
|
201
|
+
expected.
|
|
202
|
+
</li>
|
|
203
|
+
<li>
|
|
204
|
+
In <b>Settings</b>, search for <b>CA certificate</b>. Or go to
|
|
205
|
+
Security → Encryption & credentials → Install a certificate →
|
|
206
|
+
CA certificate.
|
|
207
|
+
</li>
|
|
208
|
+
<li>
|
|
209
|
+
Tap <b>Install anyway</b> and pick the file in Downloads. Android
|
|
210
|
+
asks for a screen lock if there is none.
|
|
211
|
+
</li>
|
|
212
|
+
<li>Come back here, the check above turns green.</li>
|
|
213
|
+
</ol>
|
|
214
|
+
<p class="muted">
|
|
215
|
+
Android then shows “Network may be monitored”: expected, the device
|
|
216
|
+
trusts a certificate installed by hand. Chrome honors it, most apps
|
|
217
|
+
do not.
|
|
218
|
+
</p>
|
|
219
|
+
</details>
|
|
220
|
+
<details data-platform="ios" open>
|
|
221
|
+
<summary>
|
|
222
|
+
iPhone, iPad <span class="this_device">(this device)</span>
|
|
223
|
+
</summary>
|
|
224
|
+
<ol>
|
|
225
|
+
<li>
|
|
226
|
+
Open this page in <b>Safari</b>, tap
|
|
227
|
+
<b>Download the root certificate</b>, then <b>Allow</b>.
|
|
228
|
+
</li>
|
|
229
|
+
<li>
|
|
230
|
+
In <b>Settings</b>, tap <b>Profile Downloaded</b> →
|
|
231
|
+
<b>Install</b>.
|
|
232
|
+
</li>
|
|
233
|
+
<li>
|
|
234
|
+
Then
|
|
235
|
+
<b>Settings → General → About → Certificate Trust Settings</b> and
|
|
236
|
+
turn on <b class="common_name">the root certificate</b>. Without
|
|
237
|
+
this step the certificate is installed but not trusted.
|
|
238
|
+
</li>
|
|
239
|
+
<li>Come back here, the check above turns green.</li>
|
|
240
|
+
</ol>
|
|
241
|
+
</details>
|
|
242
|
+
<details data-platform="desktop" open>
|
|
243
|
+
<summary>
|
|
244
|
+
Computer <span class="this_device">(this device)</span>
|
|
245
|
+
</summary>
|
|
246
|
+
<p>
|
|
247
|
+
On the computer running the dev server,
|
|
248
|
+
<code>npx @jsenv/https-local init</code> installs and trusts the
|
|
249
|
+
root certificate.
|
|
250
|
+
</p>
|
|
251
|
+
<p>
|
|
252
|
+
On another computer, download it and add it to the trusted root
|
|
253
|
+
certificates of the system. On macOS: open the file, then set it to
|
|
254
|
+
“Always Trust” in Keychain Access.
|
|
255
|
+
</p>
|
|
256
|
+
</details>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<details open>
|
|
260
|
+
<summary>Good to know</summary>
|
|
261
|
+
<p>
|
|
262
|
+
The device trusts anything signed by this root certificate. Its key
|
|
263
|
+
never leaves the computer running the dev server, only the certificate
|
|
264
|
+
is served: the trade-off already made on that computer.
|
|
265
|
+
</p>
|
|
266
|
+
<p>
|
|
267
|
+
<b>See the fingerprint, or remove it.</b> Android: Settings →
|
|
268
|
+
Encryption & credentials → Trusted credentials → User → tap it.
|
|
269
|
+
iPhone, iPad: Settings → General → VPN & Device Management → the
|
|
270
|
+
profile.
|
|
271
|
+
</p>
|
|
272
|
+
</details>
|
|
273
|
+
</main>
|
|
274
|
+
|
|
275
|
+
<script type="module">
|
|
276
|
+
const readPlatform = () => {
|
|
277
|
+
const userAgent = window.navigator.userAgent;
|
|
278
|
+
if (/android/i.test(userAgent)) {
|
|
279
|
+
return "android";
|
|
280
|
+
}
|
|
281
|
+
if (/iPhone|iPad|iPod/.test(userAgent)) {
|
|
282
|
+
return "ios";
|
|
283
|
+
}
|
|
284
|
+
// iPadOS Safari introduces itself as a Mac
|
|
285
|
+
if (
|
|
286
|
+
/Macintosh/.test(userAgent) &&
|
|
287
|
+
window.navigator.maxTouchPoints > 1
|
|
288
|
+
) {
|
|
289
|
+
return "ios";
|
|
290
|
+
}
|
|
291
|
+
return "desktop";
|
|
292
|
+
};
|
|
293
|
+
const platform = readPlatform();
|
|
294
|
+
const platforms = document.querySelector("#platforms");
|
|
295
|
+
for (const details of platforms.querySelectorAll("[data-platform]")) {
|
|
296
|
+
if (details.getAttribute("data-platform") === platform) {
|
|
297
|
+
details.setAttribute("data-current", "");
|
|
298
|
+
platforms.prepend(details);
|
|
299
|
+
} else {
|
|
300
|
+
details.open = false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// http and https share the port of the dev server
|
|
305
|
+
const port =
|
|
306
|
+
window.location.port ||
|
|
307
|
+
(window.location.protocol === "https:" ? "443" : "80");
|
|
308
|
+
const httpsOrigin = new URL(`https://${window.location.hostname}:${port}`)
|
|
309
|
+
.origin;
|
|
310
|
+
const continueLink = document.querySelector("#continue");
|
|
311
|
+
continueLink.href = `${httpsOrigin}/`;
|
|
312
|
+
|
|
313
|
+
const statusCard = document.querySelector("#status_card");
|
|
314
|
+
const status = document.querySelector("#status");
|
|
315
|
+
const download = document.querySelector("#download");
|
|
316
|
+
const checkTrust = async () => {
|
|
317
|
+
try {
|
|
318
|
+
// rejects as long as the certificate is not trusted, unless this
|
|
319
|
+
// browser clicked through the certificate warning for this host
|
|
320
|
+
await fetch(`${httpsOrigin}${window.location.pathname}ping`, {
|
|
321
|
+
mode: "no-cors",
|
|
322
|
+
cache: "no-store",
|
|
323
|
+
});
|
|
324
|
+
} catch {
|
|
325
|
+
statusCard.setAttribute("data-state", "untrusted");
|
|
326
|
+
status.textContent = "This device does not trust the dev server yet.";
|
|
327
|
+
continueLink.removeAttribute("data-primary");
|
|
328
|
+
download.setAttribute("data-primary", "");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
statusCard.setAttribute("data-state", "trusted");
|
|
332
|
+
status.textContent = "✓ This device trusts the dev server.";
|
|
333
|
+
continueLink.setAttribute("data-primary", "");
|
|
334
|
+
download.removeAttribute("data-primary");
|
|
335
|
+
};
|
|
336
|
+
checkTrust();
|
|
337
|
+
// back from the settings where the certificate was installed
|
|
338
|
+
document.addEventListener("visibilitychange", () => {
|
|
339
|
+
if (document.visibilityState === "visible") {
|
|
340
|
+
checkTrust();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const response = await fetch("./root.json");
|
|
345
|
+
const { fingerprint256, commonName } = await response.json();
|
|
346
|
+
document.querySelector("#fingerprint").textContent = fingerprint256;
|
|
347
|
+
for (const element of document.querySelectorAll(".common_name")) {
|
|
348
|
+
element.textContent = commonName;
|
|
349
|
+
}
|
|
350
|
+
</script>
|
|
351
|
+
</body>
|
|
352
|
+
</html>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Lets another device (a phone) trust the root certificate that signs the dev
|
|
3
|
+
* server certificate, so it opens the dev server over https without a warning.
|
|
4
|
+
*
|
|
5
|
+
* Everything under HTTPS_TRUST_PATHNAME is served over plain http too (see the
|
|
6
|
+
* redirectHttpToHttps passed by startDevServer): it is the one page that must
|
|
7
|
+
* load before the certificate is trusted, over https it would show the very
|
|
8
|
+
* warning it exists to remove. Only the certificate is served, never its key.
|
|
9
|
+
*
|
|
10
|
+
* The page is sent as written, not cooked like the project pages: it needs
|
|
11
|
+
* nothing cooking injects, and a project plugin rewriting html must not be able
|
|
12
|
+
* to break the one page reached before trust.
|
|
13
|
+
*
|
|
14
|
+
* What it means for the device, and why the fingerprint must be compared with
|
|
15
|
+
* the terminal: docs/users/b_dev/b_dev.md, "Trusting it on a phone".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
export const HTTPS_TRUST_PATHNAME = "/.internal/https/";
|
|
21
|
+
|
|
22
|
+
const httpsTrustPageFileUrl = new URL(
|
|
23
|
+
"./client/https_trust.html",
|
|
24
|
+
import.meta.url,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// rootCertificate is a node:crypto X509Certificate
|
|
28
|
+
export const devServerPluginHttpsTrust = ({ rootCertificate }) => {
|
|
29
|
+
const commonName = readCommonName(rootCertificate.subject);
|
|
30
|
+
const rootCertificateInfo = {
|
|
31
|
+
fingerprint256: rootCertificate.fingerprint256,
|
|
32
|
+
commonName,
|
|
33
|
+
};
|
|
34
|
+
// "https local root certificate" -> "https_local_root_certificate.crt"
|
|
35
|
+
const rootCertificateFilename = `${commonName.replace(/[^\w.-]+/g, "_")}.crt`;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: "jsenv:https_trust",
|
|
39
|
+
routes: [
|
|
40
|
+
{
|
|
41
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}`,
|
|
42
|
+
description:
|
|
43
|
+
"Page to trust the dev server https on another device (a phone). Served over http.",
|
|
44
|
+
declarationSource: import.meta.url,
|
|
45
|
+
fetch: () => {
|
|
46
|
+
return new Response(readFileSync(httpsTrustPageFileUrl), {
|
|
47
|
+
headers: { "content-type": "text/html" },
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}root.crt`,
|
|
53
|
+
description: "The root certificate signing the dev server certificate.",
|
|
54
|
+
declarationSource: import.meta.url,
|
|
55
|
+
fetch: () => {
|
|
56
|
+
return new Response(rootCertificate.toString(), {
|
|
57
|
+
headers: {
|
|
58
|
+
"content-type": "application/x-x509-ca-cert",
|
|
59
|
+
"content-disposition": `attachment; filename="${rootCertificateFilename}"`,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}root.json`,
|
|
66
|
+
description: "Fingerprint and name of the root certificate.",
|
|
67
|
+
declarationSource: import.meta.url,
|
|
68
|
+
fetch: () => {
|
|
69
|
+
return Response.json(rootCertificateInfo);
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
endpoint: `GET ${HTTPS_TRUST_PATHNAME}ping`,
|
|
74
|
+
description:
|
|
75
|
+
"Fetched over https by the page: it fails as long as the device does not trust the certificate.",
|
|
76
|
+
declarationSource: import.meta.url,
|
|
77
|
+
fetch: () => {
|
|
78
|
+
return new Response(null, { status: 204 });
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// "CN=https local root certificate\nO=..." -> "https local root certificate"
|
|
86
|
+
const readCommonName = (subject) => {
|
|
87
|
+
for (const line of subject.split("\n")) {
|
|
88
|
+
if (line.startsWith("CN=")) {
|
|
89
|
+
return line.slice("CN=".length);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return subject;
|
|
93
|
+
};
|