@jsenv/core 41.5.35 → 41.5.38

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.
@@ -0,0 +1,362 @@
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>:root {
8
+ --lightningcss-light: initial;
9
+ --lightningcss-dark: ;
10
+ color-scheme: light dark;
11
+ --ink: #111827;
12
+ --muted: #6b7280;
13
+ --paper: #f3f4f6;
14
+ --card: #fff;
15
+ --line: #e5e7eb;
16
+ --accent: #2563eb;
17
+ --accent-ink: #fff;
18
+ --ok: #15803d;
19
+ --ok-paper: #dcfce7;
20
+ --warn: #b45309;
21
+ --warn-paper: #fef3c7;
22
+ }
23
+
24
+ @media (prefers-color-scheme: dark) {
25
+ :root {
26
+ --lightningcss-light: ;
27
+ --lightningcss-dark: initial;
28
+ --ink: #e5e7eb;
29
+ --muted: #9ca3af;
30
+ --paper: #0b1020;
31
+ --card: #111827;
32
+ --line: #1f2937;
33
+ --accent: #3b82f6;
34
+ --ok: #4ade80;
35
+ --ok-paper: #052e16;
36
+ --warn: #fbbf24;
37
+ --warn-paper: #2d1a02;
38
+ }
39
+ }
40
+
41
+ body {
42
+ color: var(--ink);
43
+ background: var(--paper);
44
+ margin: 0;
45
+ padding: 16px;
46
+ font: 15px / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
47
+ }
48
+
49
+ main {
50
+ flex-direction: column;
51
+ gap: 16px;
52
+ max-width: 34rem;
53
+ margin: 0 auto;
54
+ display: flex;
55
+ }
56
+
57
+ h1 {
58
+ margin: 8px 0 0;
59
+ font-size: 1.35rem;
60
+ }
61
+
62
+ h2 {
63
+ margin: 0;
64
+ font-size: 1rem;
65
+ }
66
+
67
+ p {
68
+ margin: 0;
69
+ }
70
+
71
+ ol {
72
+ margin: 0;
73
+ padding-left: 1.3em;
74
+ }
75
+
76
+ li + li {
77
+ margin-top: 6px;
78
+ }
79
+
80
+ code {
81
+ font: .85em ui-monospace, SFMono-Regular, Menlo, monospace;
82
+ }
83
+
84
+ .muted {
85
+ color: var(--muted);
86
+ }
87
+
88
+ .card {
89
+ background: var(--card);
90
+ border: 1px solid var(--line);
91
+ border-radius: 10px;
92
+ flex-direction: column;
93
+ gap: 10px;
94
+ padding: 14px 16px;
95
+ display: flex;
96
+ }
97
+
98
+ .button {
99
+ color: var(--accent);
100
+ text-align: center;
101
+ border: 1px solid var(--accent);
102
+ border-radius: 8px;
103
+ padding: 12px 16px;
104
+ font-weight: 600;
105
+ text-decoration: none;
106
+ display: block;
107
+ }
108
+
109
+ .button[data-primary] {
110
+ color: var(--accent-ink);
111
+ background: var(--accent);
112
+ }
113
+
114
+ #status {
115
+ font-weight: 600;
116
+ }
117
+
118
+ #status_card[data-state="trusted"] {
119
+ background: var(--ok-paper);
120
+ border-color: var(--ok);
121
+ }
122
+
123
+ #status_card[data-state="trusted"] #status {
124
+ color: var(--ok);
125
+ }
126
+
127
+ #status_card[data-state="untrusted"] {
128
+ background: var(--warn-paper);
129
+ border-color: var(--warn);
130
+ }
131
+
132
+ #status_card[data-state="untrusted"] #status {
133
+ color: var(--warn);
134
+ }
135
+
136
+ #fingerprint {
137
+ word-break: break-all;
138
+ display: block;
139
+ }
140
+
141
+ details {
142
+ background: var(--card);
143
+ border: 1px solid var(--line);
144
+ border-radius: 10px;
145
+ padding: 14px 16px;
146
+ }
147
+
148
+ summary {
149
+ cursor: pointer;
150
+ font-weight: 600;
151
+ }
152
+
153
+ details[open] summary {
154
+ margin-bottom: 10px;
155
+ }
156
+
157
+ details .this_device {
158
+ color: var(--muted);
159
+ font-weight: 400;
160
+ display: none;
161
+ }
162
+
163
+ details[data-current] .this_device {
164
+ display: inline;
165
+ }
166
+
167
+ details p + ol, details ol + p, details p + p {
168
+ margin-top: 10px;
169
+ }
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <main>
174
+ <h1>Trust the dev server https</h1>
175
+ <p class="muted">
176
+ Install the root certificate of the computer running the dev server and
177
+ this device opens it over https without a warning. Service workers,
178
+ push, install and the other secure-context APIs need it.
179
+ </p>
180
+
181
+ <div id="status_card" class="card" data-state="checking">
182
+ <p id="status">Checking whether this device trusts the dev server…</p>
183
+ <a id="continue" class="button" href="/">
184
+ Continue to https
185
+ </a>
186
+ </div>
187
+
188
+ <div class="card">
189
+ <a id="download" class="button" href="./root.crt" data-primary="">
190
+ Download the root certificate
191
+ </a>
192
+ <p>
193
+ <span class="muted">SHA-256 fingerprint</span>
194
+ <code id="fingerprint">…</code>
195
+ </p>
196
+ <p class="muted">
197
+ Once installed, the fingerprint the device shows must match the one
198
+ the dev server prints in its terminal: this page travels over plain
199
+ http, so the network could swap the file.
200
+ </p>
201
+ </div>
202
+
203
+ <div id="platforms">
204
+ <details data-platform="android" open="">
205
+ <summary>
206
+ Android <span class="this_device">(this device)</span>
207
+ </summary>
208
+ <ol>
209
+ <li>
210
+ Tap <b>Download the root certificate</b>. Opening the file only
211
+ says CA certificates must be installed from Settings: that is
212
+ expected.
213
+ </li>
214
+ <li>
215
+ In <b>Settings</b>, search for <b>CA certificate</b>. Or go to
216
+ Security → Encryption &amp; credentials → Install a certificate →
217
+ CA certificate.
218
+ </li>
219
+ <li>
220
+ Tap <b>Install anyway</b> and pick the file in Downloads. Android
221
+ asks for a screen lock if there is none.
222
+ </li>
223
+ <li>Come back here, the check above turns green.</li>
224
+ </ol>
225
+ <p class="muted">
226
+ Android then shows “Network may be monitored”: expected, the device
227
+ trusts a certificate installed by hand. Chrome honors it, most apps
228
+ do not.
229
+ </p>
230
+ </details>
231
+ <details data-platform="ios" open="">
232
+ <summary>
233
+ iPhone, iPad <span class="this_device">(this device)</span>
234
+ </summary>
235
+ <ol>
236
+ <li>
237
+ Open this page in <b>Safari</b>, tap
238
+ <b>Download the root certificate</b>, then <b>Allow</b>.
239
+ </li>
240
+ <li>
241
+ In <b>Settings</b>, tap <b>Profile Downloaded</b> →
242
+ <b>Install</b>.
243
+ </li>
244
+ <li>
245
+ Then
246
+ <b>Settings → General → About → Certificate Trust Settings</b> and
247
+ turn on <b class="common_name">the root certificate</b>. Without
248
+ this step the certificate is installed but not trusted.
249
+ </li>
250
+ <li>Come back here, the check above turns green.</li>
251
+ </ol>
252
+ </details>
253
+ <details data-platform="desktop" open="">
254
+ <summary>
255
+ Computer <span class="this_device">(this device)</span>
256
+ </summary>
257
+ <p>
258
+ On the computer running the dev server,
259
+ <code>npx @jsenv/https-local init</code> installs and trusts the
260
+ root certificate.
261
+ </p>
262
+ <p>
263
+ On another computer, download it and add it to the trusted root
264
+ certificates of the system. On macOS: open the file, then set it to
265
+ “Always Trust” in Keychain Access.
266
+ </p>
267
+ </details>
268
+ </div>
269
+
270
+ <details open="">
271
+ <summary>Good to know</summary>
272
+ <p>
273
+ The device trusts anything signed by this root certificate. Its key
274
+ never leaves the computer running the dev server, only the certificate
275
+ is served: the trade-off already made on that computer.
276
+ </p>
277
+ <p>
278
+ <b>See the fingerprint, or remove it.</b> Android: Settings →
279
+ Encryption &amp; credentials → Trusted credentials → User → tap it.
280
+ iPhone, iPad: Settings → General → VPN &amp; Device Management → the
281
+ profile.
282
+ </p>
283
+ </details>
284
+ </main>
285
+
286
+ <script type="module">const readPlatform = () => {
287
+ const userAgent = window.navigator.userAgent;
288
+ if (/android/i.test(userAgent)) {
289
+ return "android";
290
+ }
291
+ if (/iPhone|iPad|iPod/.test(userAgent)) {
292
+ return "ios";
293
+ }
294
+ // iPadOS Safari introduces itself as a Mac
295
+ if (
296
+ /Macintosh/.test(userAgent) &&
297
+ window.navigator.maxTouchPoints > 1
298
+ ) {
299
+ return "ios";
300
+ }
301
+ return "desktop";
302
+ };
303
+ const platform = readPlatform();
304
+ const platforms = document.querySelector("#platforms");
305
+ for (const details of platforms.querySelectorAll("[data-platform]")) {
306
+ if (details.getAttribute("data-platform") === platform) {
307
+ details.setAttribute("data-current", "");
308
+ platforms.prepend(details);
309
+ } else {
310
+ details.open = false;
311
+ }
312
+ }
313
+
314
+ // http and https share the port of the dev server
315
+ const port =
316
+ window.location.port ||
317
+ (window.location.protocol === "https:" ? "443" : "80");
318
+ const httpsOrigin = new URL(`https://${window.location.hostname}:${port}`)
319
+ .origin;
320
+ const continueLink = document.querySelector("#continue");
321
+ continueLink.href = `${httpsOrigin}/`;
322
+
323
+ const statusCard = document.querySelector("#status_card");
324
+ const status = document.querySelector("#status");
325
+ const download = document.querySelector("#download");
326
+ const checkTrust = async () => {
327
+ try {
328
+ // rejects as long as the certificate is not trusted, unless this
329
+ // browser clicked through the certificate warning for this host
330
+ await fetch(`${httpsOrigin}${window.location.pathname}ping`, {
331
+ mode: "no-cors",
332
+ cache: "no-store",
333
+ });
334
+ } catch {
335
+ statusCard.setAttribute("data-state", "untrusted");
336
+ status.textContent = "This device does not trust the dev server yet.";
337
+ continueLink.removeAttribute("data-primary");
338
+ download.setAttribute("data-primary", "");
339
+ return;
340
+ }
341
+ statusCard.setAttribute("data-state", "trusted");
342
+ status.textContent = "✓ This device trusts the dev server.";
343
+ continueLink.setAttribute("data-primary", "");
344
+ download.removeAttribute("data-primary");
345
+ };
346
+ checkTrust();
347
+ // back from the settings where the certificate was installed
348
+ document.addEventListener("visibilitychange", () => {
349
+ if (document.visibilityState === "visible") {
350
+ checkTrust();
351
+ }
352
+ });
353
+
354
+ const response = await fetch("./root.json");
355
+ const { fingerprint256, commonName } = await response.json();
356
+ document.querySelector("#fingerprint").textContent = fingerprint256;
357
+ for (const element of document.querySelectorAll(".common_name")) {
358
+ element.textContent = commonName;
359
+ }
360
+ </script>
361
+ </body>
362
+ </html>
@@ -808,4 +808,225 @@ const isCommandKey = (event) =>
808
808
  const isEditorKey = (event) =>
809
809
  (event.key === "e" || event.key === "E") && isCommandKey(event);
810
810
 
811
- export { asPageUrl, isCommandKey, isEditorKey, loadPages, openPagePicker };
811
+ /*
812
+ * cmd+K / ctrl+K on any dev-served page: the .html files the server serves, as a
813
+ * tree one walks, filter as you type, Enter to go there — cmd/ctrl+Enter to go
814
+ * there in another tab and stay here, the same thing cmd+click on a row does.
815
+ *
816
+ * The tree itself is not this file's: it is the page picker the dev server ships
817
+ * for everyone who has to ask "which page?" (see protocol_file/client/page_picker.js
818
+ * — the clients dashboard asks it too, to send another browser somewhere). What
819
+ * belongs here is the key, and what taking a row means in this tab.
820
+ *
821
+ * cmd+E / ctrl+E is the other half of the same question: instead of going to a
822
+ * page, open its file in the editor (the server does it, see
823
+ * GET /.internal/open_file/*). Pressed on the page itself it opens the page one
824
+ * is on; pressed inside the switcher it opens the row one is looking at.
825
+ *
826
+ * Injected into every page (see jsenv_plugin_page_switcher.js), which is why it
827
+ * is careful about what it takes:
828
+ * - the key is watched at the end of the bubble, on window, and a press that
829
+ * was already handled (defaultPrevented) is left alone — a page with its own
830
+ * cmd+K keeps it, and gets it first;
831
+ * - nothing is fetched, built or styled until the first time it opens;
832
+ * - everything it renders lives in a shadow root, so the page's own CSS cannot
833
+ * reach it and it cannot reach the page's.
834
+ */
835
+
836
+
837
+ // The server asks the OS to open a file in whatever editor is configured
838
+ // (VSCode here) — it takes a file url, which is why the page list carries one
839
+ // per page (see html_pages.js).
840
+ const OPEN_FILE_ENDPOINT = "/.internal/open_file/";
841
+ // Open across a reload: this is a dev tool on a page one is editing, and a hot
842
+ // reload in the middle of looking for the next page should not close what one
843
+ // was looking at. In sessionStorage, not localStorage: it says what THIS tab was
844
+ // doing a second ago, which is not something to remember tomorrow.
845
+ const OPEN_KEY = "jsenv_page_switcher_open";
846
+
847
+ // The page this script is running in, as the list spells its urls.
848
+ const currentPageUrl = () => asPageUrl(window.location.href);
849
+
850
+ const isSwitcherKey = (event) =>
851
+ (event.key === "k" || event.key === "K") && isCommandKey(event);
852
+
853
+ const FLASH_STYLE_TEXT = /* css */ `
854
+ :host {
855
+ position: fixed;
856
+ right: 16px;
857
+ bottom: 16px;
858
+ /* Above the picker's own panel: it is the switcher that triggers it. */
859
+ z-index: 2147483647;
860
+ display: block;
861
+ font-family: system-ui, sans-serif;
862
+ pointer-events: none;
863
+ }
864
+ .flash {
865
+ padding: 8px 14px;
866
+ color: light-dark(#0f172a, #e2e8f0);
867
+ font-size: 13px;
868
+ background: light-dark(white, #1e293b);
869
+ border-radius: 8px;
870
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
871
+ color-scheme: light dark;
872
+ }
873
+ .flash[data-error] {
874
+ color: light-dark(#991b1b, #fecaca);
875
+ background: light-dark(#fee2e2, #7f1d1d);
876
+ }
877
+ `;
878
+
879
+ // Opening a file in an editor happens in another application, on another
880
+ // screen sometimes: without a word here, a press that failed and a press that
881
+ // worked look exactly the same. Its own host and its own shadow root, so it
882
+ // can be shown whether or not the switcher is open.
883
+ let flashHost = null;
884
+ let flashBox = null;
885
+ let flashTimeout = null;
886
+ const flash = (message, isError) => {
887
+ if (!flashHost) {
888
+ flashHost = document.createElement("div");
889
+ const shadow = flashHost.attachShadow({ mode: "open" });
890
+ const style = document.createElement("style");
891
+ style.textContent = FLASH_STYLE_TEXT;
892
+ flashBox = document.createElement("div");
893
+ flashBox.className = "flash";
894
+ shadow.append(style, flashBox);
895
+ }
896
+ flashBox.textContent = message;
897
+ flashBox.toggleAttribute("data-error", Boolean(isError));
898
+ // Appended last every time, so it sits above the picker's host when both
899
+ // are on the page and they share the same z-index.
900
+ document.body.append(flashHost);
901
+ window.clearTimeout(flashTimeout);
902
+ flashTimeout = window.setTimeout(() => flashHost.remove(), 2500);
903
+ };
904
+
905
+ const openInEditor = async (file) => {
906
+ if (!file || !file.fileUrl) {
907
+ flash("This page is not a file the server lists.", true);
908
+ return;
909
+ }
910
+ flash(`Opening ${file.name || file.url} in editor…`);
911
+ try {
912
+ const response = await fetch(
913
+ `${OPEN_FILE_ENDPOINT}${encodeURIComponent(file.fileUrl)}`,
914
+ );
915
+ if (response.status === 404) {
916
+ // The route exists only when the server is willing to expose the
917
+ // machine it runs on (see start_server.js).
918
+ flash("This server does not open files in an editor.", true);
919
+ } else if (!response.ok) {
920
+ flash(`Editor said no (${response.status}).`, true);
921
+ }
922
+ } catch {
923
+ flash("Could not reach the dev server.", true);
924
+ }
925
+ };
926
+
927
+ const rememberOpen = (isOpen) => {
928
+ try {
929
+ if (isOpen) {
930
+ window.sessionStorage.setItem(OPEN_KEY, "1");
931
+ } else {
932
+ window.sessionStorage.removeItem(OPEN_KEY);
933
+ }
934
+ } catch {
935
+ // private mode, quota — it just will not come back
936
+ }
937
+ };
938
+ const wasOpen = () => {
939
+ try {
940
+ return window.sessionStorage.getItem(OPEN_KEY) === "1";
941
+ } catch {
942
+ return false;
943
+ }
944
+ };
945
+
946
+ let picker = null;
947
+
948
+ // The same key opens it and puts it away: pressed while it is up, cmd+K is the
949
+ // reader saying "not this, back to what I was doing".
950
+ const toggleSwitcher = () => {
951
+ if (picker) {
952
+ picker.close();
953
+ return;
954
+ }
955
+ openSwitcher();
956
+ };
957
+
958
+ const openSwitcher = () => {
959
+ picker = openPagePicker({
960
+ // The other key is written where one is already looking: a shortcut nobody
961
+ // is told about is a shortcut nobody presses. Both names, not the one this
962
+ // platform uses — the reader knows which of the two their keyboard has, and
963
+ // it keeps what the panel says the same everywhere.
964
+ placeholder: "Go to page… (cmd/ctrl+Enter: new tab, cmd/ctrl+E: editor)",
965
+ hereUrl: window.location.href,
966
+ // Going there is something this browser can do, so a row is a link: its
967
+ // menu, its middle click and its cmd+click all work.
968
+ getHref: (page) => page.url,
969
+ onPick: (page) => {
970
+ // Going somewhere is done with it: what reopens across a reload is a
971
+ // switcher one was still using.
972
+ rememberOpen(false);
973
+ window.location.href = page.url;
974
+ },
975
+ onEdit: (page) => {
976
+ // Done with the switcher: the answer to "where does this live" arrives
977
+ // in the editor, not here.
978
+ rememberOpen(false);
979
+ openInEditor(page);
980
+ },
981
+ onClose: () => {
982
+ picker = null;
983
+ rememberOpen(false);
984
+ },
985
+ });
986
+ rememberOpen(true);
987
+ };
988
+
989
+ // The page one is looking at, in the editor. The list is where the file url
990
+ // comes from, so a page the server does not list (an @fs url, something under
991
+ // node_modules) says so rather than opening the wrong thing.
992
+ const openCurrentPageInEditor = async () => {
993
+ const here = currentPageUrl();
994
+ const pages = await loadPages();
995
+ const page = pages.find((candidate) => candidate.url === here);
996
+ openInEditor(page && { ...page, name: here.split("/").pop() });
997
+ };
998
+
999
+ // Last in line, on purpose: on window (the end of the bubble), and registered
1000
+ // once the page has loaded — so every listener the page set up while parsing is
1001
+ // already in place and has already had this press. If any of them called
1002
+ // preventDefault, the key was theirs and nothing happens here.
1003
+ const listenSwitcherKey = () => {
1004
+ window.addEventListener("keydown", (event) => {
1005
+ if (event.defaultPrevented) {
1006
+ return;
1007
+ }
1008
+ if (isSwitcherKey(event)) {
1009
+ // Ours now: the browser has its own use for cmd+K (the address bar),
1010
+ // which it must not get.
1011
+ event.preventDefault();
1012
+ toggleSwitcher();
1013
+ return;
1014
+ }
1015
+ if (isEditorKey(event)) {
1016
+ event.preventDefault();
1017
+ openCurrentPageInEditor();
1018
+ }
1019
+ });
1020
+ };
1021
+
1022
+ const setup = () => {
1023
+ listenSwitcherKey();
1024
+ if (wasOpen()) {
1025
+ openSwitcher();
1026
+ }
1027
+ };
1028
+ if (document.readyState === "complete") {
1029
+ setup();
1030
+ } else {
1031
+ window.addEventListener("load", setup, { once: true });
1032
+ }