@uniflowed/vite 0.0.0-alpha.12 → 0.0.0-alpha.14

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,366 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // The channel a browser reports on.
6
+ //
7
+ // Every other uf diagnostic — a type error, a lint finding, a failing test, a
8
+ // page that rendered its error boundary — arrives in the terminal the
9
+ // developer already has open. A diagnostic the *browser* produces had nowhere
10
+ // to go: the page is the only process that knows about it, and it has no
11
+ // channel back. So it lived in a browser overlay, which has to be noticed, in
12
+ // a window that may not be in front, by somebody who does not know to look.
13
+ //
14
+ // This is that channel, and it is deliberately one channel rather than one per
15
+ // feature. Two things report on it today:
16
+ //
17
+ // * `POST /__uf/diagnostic` — a diagnostic a browser-side runtime produced
18
+ // and wants a person to read. `@uniflowed/router`'s `internal/diagnostics.js`
19
+ // is the client half, and the hydration-mismatch report beside it is what
20
+ // calls it.
21
+ // * `POST /__uf/vitals` — the five numbers `@uniflowed/web/vitals`
22
+ // measures, posted by `vitalsBeacon()`. In production a project points the
23
+ // beacon at an endpoint of its own; in development there was nothing at
24
+ // the default path, so the one place the numbers are most useful — while
25
+ // you are looking at the page — was the one place they went nowhere.
26
+ //
27
+ // Both end up as the same `diagnostic` event on the driver's control channel
28
+ // (see `./events.js`), which is what gets them uf's own rendering: a severity,
29
+ // a location, and a code frame when the browser had a position to give.
30
+ //
31
+ // # The terminal, and not a browser overlay
32
+ //
33
+ // ubugeeei-prod/uf#557 asked for the vitals to be shown in the overlay. They
34
+ // are shown in the terminal instead, and the later issue that generalised this
35
+ // — #583 — is the argument: a diagnostic that exists only in a browser window
36
+ // has to be noticed by somebody who does not know to look, which is the defect
37
+ // rather than the delivery. Sending these *back* to an overlay would also be
38
+ // circular for the diagnostic half, which arrived from the page in the first
39
+ // place, and an overlay covers the page a performance number is about. What it
40
+ // costs is that a reader watching the browser rather than the terminal sees
41
+ // nothing until they look, which is where every other uf diagnostic already
42
+ // is.
43
+ //
44
+ // # Nothing leaves the machine
45
+ //
46
+ // This module opens no connection. It reads a request that the page on the
47
+ // other end of the dev server's own socket made, writes a line to the terminal
48
+ // that started the dev server, and answers `204`. There is no destination, no
49
+ // third party and nothing to configure, in development or otherwise.
50
+ //
51
+ // # Why the paths are written out here
52
+ //
53
+ // `VITALS_ENDPOINT` in `@uniflowed/web/vitals` and `DIAGNOSTIC_ENDPOINT` in
54
+ // `@uniflowed/router`'s `internal/diagnostics.js` are the same two strings, and
55
+ // they are the contract. They cannot be *imported* here: this module is loaded
56
+ // by Vite before any Flow transform exists, and both of those are Flow. So they
57
+ // are written out, and `tests/library/dev-channel.test.js` asserts that all
58
+ // four spellings agree — a duplicated constant with a test on it is honest, and
59
+ // one without is how the browser ends up posting to a path nothing serves.
60
+ //
61
+ // # Why `/__uf/`
62
+ //
63
+ // A directory under `app/` whose name begins with `_` is not a route, so no
64
+ // application can put anything at this prefix and nothing here can shadow a
65
+ // path a project wrote. That is what makes it safe as a default destination
66
+ // and available to the dev server.
67
+
68
+ /** Where `@uniflowed/router`'s `reportDiagnostic` posts. */
69
+ export const DIAGNOSTIC_ENDPOINT = "/__uf/diagnostic";
70
+
71
+ /** Where `@uniflowed/web/vitals`'s `vitalsBeacon()` posts by default. */
72
+ export const VITALS_ENDPOINT = "/__uf/vitals";
73
+
74
+ /**
75
+ * The most a report may weigh.
76
+ *
77
+ * A diagnostic is a headline and a few lines of context; a vitals report is
78
+ * five numbers. Neither is close to this, and the ceiling is here because the
79
+ * body arrives from a page — "no unbounded anything" in `docs/security.md`
80
+ * covers a dev server reading a request as much as it covers a production one,
81
+ * and a page with a runaway loop in it must not be able to make `uf dev` grow
82
+ * without bound.
83
+ */
84
+ export const MAX_BODY_BYTES = 64 * 1024;
85
+
86
+ /** The most detail lines one diagnostic prints. */
87
+ const MAX_DETAIL_LINES = 40;
88
+
89
+ /** The most characters any single line of a diagnostic prints. */
90
+ const MAX_LINE_CHARS = 400;
91
+
92
+ /** The most metrics one vitals report is read for; there are five names. */
93
+ const MAX_VITALS = 16;
94
+
95
+ /** The most characters a metric's name or its rating may print as. */
96
+ const MAX_NAME_CHARS = 32;
97
+
98
+ /** The severities the channel accepts, and the words the terminal uses. */
99
+ const SEVERITIES = new Set(["error", "warn", "info"]);
100
+
101
+ /**
102
+ * The connect middleware that answers the channel.
103
+ *
104
+ * Mounted **before** the application middleware, so a request under `/__uf/`
105
+ * never reaches a project's `_uf.middleware.js` or its route table. A guard
106
+ * that ran for a page's own telemetry would be a guard asked a question the
107
+ * application never asks, and one that redirected it would turn a report into
108
+ * a login page.
109
+ *
110
+ * `report` is injected rather than reached for so that this module can be
111
+ * driven without a terminal, a socket or a driver; `internal/events.js`'s
112
+ * `emit` is what the plugin passes.
113
+ *
114
+ * @param {(diagnostic: object) => void} report
115
+ */
116
+ export function createChannelMiddleware(report) {
117
+ return async function channel(request, response, next) {
118
+ const pathname = (request.url ?? "/").split("?")[0];
119
+ const isVitals = pathname === VITALS_ENDPOINT;
120
+ if (!isVitals && pathname !== DIAGNOSTIC_ENDPOINT) {
121
+ next();
122
+ return;
123
+ }
124
+
125
+ // A `GET` on either path is somebody checking whether the dev server has
126
+ // them, and `405` with `Allow` answers that exactly. `404` would have said
127
+ // the opposite of the truth.
128
+ if (request.method !== "POST") {
129
+ response.statusCode = 405;
130
+ response.setHeader("allow", "POST");
131
+ response.end();
132
+ return;
133
+ }
134
+
135
+ let body;
136
+ try {
137
+ body = await readBody(request, MAX_BODY_BYTES);
138
+ } catch {
139
+ // A socket that went away mid-body. There is nothing to report and
140
+ // nobody left to answer.
141
+ response.statusCode = 400;
142
+ response.end();
143
+ return;
144
+ }
145
+ if (body == null) {
146
+ response.statusCode = 413;
147
+ response.end();
148
+ return;
149
+ }
150
+
151
+ let payload = null;
152
+ try {
153
+ payload = JSON.parse(body);
154
+ } catch {
155
+ payload = null;
156
+ }
157
+ const diagnostic = isVitals ? vitalsDiagnostic(payload) : browserDiagnostic(payload);
158
+ if (diagnostic == null) {
159
+ // The body was not the shape this path promises. Refused rather than
160
+ // guessed at: a diagnostic assembled out of a malformed report is a line
161
+ // in somebody's terminal that describes nothing.
162
+ response.statusCode = 400;
163
+ response.end();
164
+ return;
165
+ }
166
+
167
+ // Everything above either answered or handed the request on, so nothing
168
+ // below can leave one hanging — and an exception from `report` is the dev
169
+ // server's own failure rather than the page's, so it goes to Vite's error
170
+ // handler like any other. An `async` connect middleware whose rejection
171
+ // nobody catches is an unhandled rejection, which on a modern Node ends
172
+ // the process: `uf dev` would exit on a malformed telemetry post.
173
+ try {
174
+ report(diagnostic);
175
+ } catch (error) {
176
+ next(error);
177
+ return;
178
+ }
179
+ // No body, and nothing about the machine in the answer. The page posted
180
+ // this and is not owed a reading of it back.
181
+ response.statusCode = 204;
182
+ response.end();
183
+ };
184
+ }
185
+
186
+ /**
187
+ * One diagnostic a browser-side runtime produced, or `null`.
188
+ *
189
+ * Every field is checked and every string is bounded, because all of it is
190
+ * page-authored: a hydration mismatch on a page whose difference is in
191
+ * somebody's comment carries that comment into this terminal. Nothing here is
192
+ * interpreted — the terminal renderer prints text — but a report with a
193
+ * thousand lines in it would still scroll the reason for it off the screen.
194
+ *
195
+ * @param {unknown} payload
196
+ */
197
+ export function browserDiagnostic(payload) {
198
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) return null;
199
+ const message = line(payload.message);
200
+ if (message === "") return null;
201
+
202
+ const severity = SEVERITIES.has(payload.severity) ? payload.severity : "error";
203
+ const diagnostic = { severity, message };
204
+ const origin = line(payload.url);
205
+ if (origin !== "") diagnostic.origin = origin;
206
+ const detail = lines(payload.detail);
207
+ if (detail.length > 0) diagnostic.detail = detail;
208
+ // A position, when the browser had one. It is what turns the status line
209
+ // into a code frame on the other side, and a browser that only knows "this
210
+ // component" rather than "this line" is expected: the frame is the better
211
+ // rendering when it is available and never a requirement.
212
+ const file = line(payload.file);
213
+ if (file !== "" && Number.isInteger(payload.line) && payload.line > 0) {
214
+ diagnostic.file = file;
215
+ diagnostic.line = payload.line;
216
+ if (Number.isInteger(payload.column) && payload.column >= 0) {
217
+ diagnostic.column = payload.column;
218
+ }
219
+ }
220
+ return diagnostic;
221
+ }
222
+
223
+ /**
224
+ * A `VitalsReport` as one diagnostic, or `null` when it carries no metric.
225
+ *
226
+ * One diagnostic per report rather than one per metric, because the beacon
227
+ * already coalesces across a microtask and a page load would otherwise be five
228
+ * separate lines interleaved with whatever else the terminal is saying. The
229
+ * severity is the worst rating in the report, which is the rule the issue asks
230
+ * for: a rating that is not `good` is the interesting one and has to read as
231
+ * one.
232
+ *
233
+ * @param {unknown} payload
234
+ */
235
+ export function vitalsDiagnostic(payload) {
236
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) return null;
237
+ if (!Array.isArray(payload.vitals)) return null;
238
+
239
+ const measured = [];
240
+ for (const vital of payload.vitals.slice(0, MAX_VITALS)) {
241
+ if (vital == null || typeof vital !== "object") continue;
242
+ if (typeof vital.name !== "string" || typeof vital.value !== "number") continue;
243
+ if (!Number.isFinite(vital.value)) continue;
244
+ // The name and the rating are the page's strings, not this module's, even
245
+ // though a beacon written by `@uniflowed/web/vitals` only ever sends the
246
+ // five names and the three ratings. Anything can post here, so they go
247
+ // through the same bounding and control-character scrub as a diagnostic's
248
+ // own text, and a *word* has no business being longer than a word.
249
+ const name = line(vital.name).slice(0, MAX_NAME_CHARS);
250
+ if (name === "") continue;
251
+ const rating = line(vital.rating).slice(0, MAX_NAME_CHARS) || "unknown";
252
+ measured.push({ name, value: vital.value, rating });
253
+ }
254
+ if (measured.length === 0) return null;
255
+
256
+ // Worst first, so the line the reader needs is the line under the headline
257
+ // rather than wherever the browser happened to finish measuring.
258
+ measured.sort((left, right) => severityOf(right.rating) - severityOf(left.rating));
259
+ const worst = measured[0];
260
+ const severity = ratingSeverity(worst.rating);
261
+ const message =
262
+ severity === "info"
263
+ ? `web vitals: ${measured.map((vital) => vital.name).join(", ")} good`
264
+ : `web vitals: ${worst.name} is ${worst.rating} (${formatValue(worst)})`;
265
+
266
+ const diagnostic = {
267
+ severity,
268
+ message,
269
+ detail: measured.map((vital) => `${vital.name} ${formatValue(vital)} — ${vital.rating}`),
270
+ };
271
+ const origin = line(payload.url);
272
+ if (origin !== "") diagnostic.origin = origin;
273
+ return diagnostic;
274
+ }
275
+
276
+ /** How bad a rating is, for ordering; an unknown word sorts as the worst. */
277
+ function severityOf(rating) {
278
+ if (rating === "good") return 0;
279
+ if (rating === "needs-improvement") return 1;
280
+ return 2;
281
+ }
282
+
283
+ /** The channel severity a rating maps to. */
284
+ function ratingSeverity(rating) {
285
+ if (rating === "good") return "info";
286
+ if (rating === "needs-improvement") return "warn";
287
+ return "error";
288
+ }
289
+
290
+ /**
291
+ * A metric's value with its unit.
292
+ *
293
+ * CLS is a unitless layout-shift score and everything else is milliseconds,
294
+ * which is the one thing a reader has to know to act on the number — a `0.24`
295
+ * printed as `0.24 ms` reads as the best result in the report rather than a
296
+ * failing one.
297
+ */
298
+ function formatValue(vital) {
299
+ if (vital.name === "CLS") return String(Math.round(vital.value * 1000) / 1000);
300
+ return `${Math.round(vital.value)} ms`;
301
+ }
302
+
303
+ /** One bounded single-line string, or `""` for anything that is not one. */
304
+ function line(value) {
305
+ if (typeof value !== "string") return "";
306
+ return printable(value).trim();
307
+ }
308
+
309
+ /** A bounded list of bounded lines, from a string or an array of them. */
310
+ function lines(value) {
311
+ const source = typeof value === "string" ? value.split("\n") : value;
312
+ if (!Array.isArray(source)) return [];
313
+ const kept = [];
314
+ for (const entry of source) {
315
+ if (kept.length === MAX_DETAIL_LINES) {
316
+ kept.push("…");
317
+ break;
318
+ }
319
+ if (typeof entry !== "string") continue;
320
+ kept.push(printable(entry));
321
+ }
322
+ return kept;
323
+ }
324
+
325
+ /**
326
+ * One line of page-authored text, safe to write to a terminal and bounded.
327
+ *
328
+ * Every control character becomes a space, `ESC` included, and that is the
329
+ * point rather than tidiness: what is being rendered was written by a page, and
330
+ * a page that could put `ESC [` into a diagnostic could move the cursor,
331
+ * recolour the rest of the session or overwrite the line above its own report.
332
+ * `uf` owns this terminal — see `internal/events.js` — and nothing that arrives
333
+ * over a socket gets to draw on it.
334
+ *
335
+ * A scan rather than a regular expression, per `docs/security.md`'s "no regex
336
+ * on untrusted input": the rule is about backtracking and a character class
337
+ * cannot backtrack, but a loop needs no argument at all and is no longer.
338
+ */
339
+ function printable(value) {
340
+ let text = "";
341
+ for (const character of value.slice(0, MAX_LINE_CHARS)) {
342
+ const code = character.codePointAt(0);
343
+ text += code < 0x20 || code === 0x7f ? " " : character;
344
+ }
345
+ return text;
346
+ }
347
+
348
+ /**
349
+ * The whole request body, or `null` when it is over `limit`.
350
+ *
351
+ * Counted as it arrives rather than trusting `content-length`: the header is
352
+ * the sender's claim and the bytes are the fact, and `sendBeacon` sends
353
+ * neither a length this side should rely on nor a content type worth reading —
354
+ * a string payload goes out as `text/plain`, so the type says nothing about
355
+ * whether the body is the JSON both endpoints document.
356
+ */
357
+ async function readBody(request, limit) {
358
+ let size = 0;
359
+ const chunks = [];
360
+ for await (const chunk of request) {
361
+ size += chunk.length;
362
+ if (size > limit) return null;
363
+ chunks.push(chunk);
364
+ }
365
+ return Buffer.concat(chunks).toString("utf8");
366
+ }
@@ -54,11 +54,11 @@ export function stripAnsi(text) {
54
54
  /**
55
55
  * Report a page that rendered its error boundary instead of itself.
56
56
  *
57
- * `uf dev` has two renderers — the plugin's middleware and the driver's — and
58
- * this is the one place either of them says so, because a message written
59
- * twice is a message that ends up saying two things. The document the browser
60
- * gets is the application's error page, which is what a visitor would see;
61
- * the exception belongs in the terminal, which is uf's.
57
+ * The document the browser gets is the application's error page, which is what
58
+ * a visitor would see; the exception belongs in the terminal, which is uf's.
59
+ * There is one renderer under `uf dev` and therefore one caller of this — see
60
+ * `../index.js` — where there used to be two middlewares and a message that
61
+ * could be written twice.
62
62
  *
63
63
  * The stack is mapped back onto the Flow source first, so the frames name the
64
64
  * file that was written rather than the one that was compiled.