@uniflowed/vite 0.0.0-alpha.9 → 0.2.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.
@@ -0,0 +1,161 @@
1
+ // @noflow
2
+ // A local, bounded snapshot of the same diagnostic channel the terminal reads.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ import { format } from "node:util";
8
+
9
+ const requests = new AsyncLocalStorage();
10
+ let receive = null;
11
+
12
+ export function recordDevEvent(event) {
13
+ const request = requests.getStore();
14
+ receive?.({ ...event, ...(request == null ? {} : request), time: Date.now() });
15
+ }
16
+
17
+ export function startDevState(root, metadata) {
18
+ const directory = path.join(root, ".uf");
19
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink())
20
+ throw new Error("uf dev: .uf must not be a symlink for diagnostics");
21
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
22
+ const file = path.join(directory, "dev-state.json");
23
+ if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink())
24
+ throw new Error("uf dev: diagnostic state must not be a symlink");
25
+ const session = randomUUID();
26
+ const temporary = path.join(directory, "dev-state-" + session + ".tmp");
27
+ const state = {
28
+ schema: 1,
29
+ session,
30
+ pid: process.pid,
31
+ startedAt: Date.now(),
32
+ updatedAt: 0,
33
+ generation: 0,
34
+ errors: [],
35
+ logs: [],
36
+ routes: [],
37
+ actions: [],
38
+ };
39
+ let closed = false;
40
+ const persist = (refresh = false) => {
41
+ if (closed) return;
42
+ if (refresh || state.updatedAt === 0) {
43
+ let current;
44
+ try {
45
+ current = metadata();
46
+ state.metadataError = null;
47
+ } catch (error) {
48
+ state.metadataError = String(error.message ?? error).slice(0, 4000);
49
+ current = { routes: state.routes, actions: state.actions };
50
+ }
51
+ state.routes = current.routes.slice(0, 1000);
52
+ state.actions = current.actions.slice(0, 1000);
53
+ state.truncated = current.routes.length > 1000 || current.actions.length > 1000;
54
+ }
55
+ state.updatedAt = Date.now();
56
+ let serialized = JSON.stringify(state);
57
+ while (Buffer.byteLength(serialized) > 3 * 1024 * 1024) {
58
+ const rows = state.logs.length
59
+ ? state.logs
60
+ : state.errors.length
61
+ ? state.errors
62
+ : state.routes.length
63
+ ? state.routes
64
+ : state.actions;
65
+ if (rows.length === 0) break;
66
+ rows.shift();
67
+ state.truncated = true;
68
+ serialized = JSON.stringify(state);
69
+ }
70
+ fs.writeFileSync(temporary, serialized, { mode: 0o600 });
71
+ fs.renameSync(temporary, file);
72
+ };
73
+ const observer = (event) => {
74
+ const safe = bound(event);
75
+ if (event.event === "source-changed") {
76
+ state.generation += 1;
77
+ state.errors = [];
78
+ }
79
+ if (
80
+ event.event === "error" ||
81
+ (event.event === "log" && event.level === "error") ||
82
+ (event.event === "diagnostic" && event.severity === "error")
83
+ ) {
84
+ safe.kind ??= /hydrat/i.test(event.message ?? "")
85
+ ? "hydration"
86
+ : event.event === "diagnostic"
87
+ ? "runtime"
88
+ : "build";
89
+ state.errors.push(safe);
90
+ state.errors = state.errors.slice(-100);
91
+ }
92
+ if (["log", "diagnostic", "error", "request"].includes(event.event)) {
93
+ state.logs.push(safe);
94
+ state.logs = state.logs.slice(-150);
95
+ }
96
+ persist(event.event === "source-changed");
97
+ };
98
+ receive = observer;
99
+ persist();
100
+ // The terminal sees application console output too; keep its original behavior.
101
+ const originals = new Map();
102
+ for (const level of ["log", "info", "warn", "error", "debug"]) {
103
+ const original = console[level];
104
+ const wrapper = (...args) => {
105
+ recordDevEvent({ event: "log", level, kind: "runtime", message: format(...args) });
106
+ original.apply(console, args);
107
+ };
108
+ originals.set(level, { original, wrapper });
109
+ console[level] = wrapper;
110
+ }
111
+ const timer = setInterval(() => {
112
+ try {
113
+ persist(true);
114
+ } catch {
115
+ close();
116
+ }
117
+ }, 5000);
118
+ timer.unref?.();
119
+ const close = () => {
120
+ if (closed) return;
121
+ closed = true;
122
+ clearInterval(timer);
123
+ if (receive === observer) receive = null;
124
+ for (const [level, { original, wrapper }] of originals) {
125
+ if (console[level] === wrapper) console[level] = original;
126
+ }
127
+ try {
128
+ if (JSON.parse(fs.readFileSync(file, "utf8")).session === session) fs.unlinkSync(file);
129
+ } catch {
130
+ /* A missing or unreadable channel is reported by the MCP reader. */
131
+ }
132
+ process.off("exit", close);
133
+ };
134
+ process.once("exit", close);
135
+ return {
136
+ close,
137
+ middleware(request, response, next) {
138
+ const requestId = randomUUID();
139
+ const url = String(request.url ?? "/")
140
+ .split("?")[0]
141
+ .slice(0, 1000);
142
+ response.setHeader("x-uf-request-id", requestId);
143
+ requests.run({ requestId, url }, () => {
144
+ observer({ event: "request", requestId, url, method: request.method, time: Date.now() });
145
+ next();
146
+ });
147
+ },
148
+ };
149
+ }
150
+
151
+ function bound(value, depth = 0) {
152
+ if (typeof value === "string") return value.slice(0, 4000);
153
+ if (value == null || typeof value !== "object") return value;
154
+ if (depth > 4) return "[truncated]";
155
+ if (Array.isArray(value)) return value.slice(0, 40).map((v) => bound(v, depth + 1));
156
+ return Object.fromEntries(
157
+ Object.entries(value)
158
+ .slice(0, 32)
159
+ .map(([key, v]) => [key, bound(v, depth + 1)]),
160
+ );
161
+ }
@@ -0,0 +1,117 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // React DevTools, in `uf dev`, on purpose.
6
+ //
7
+ // DevTools does not attach to React. React attaches to *DevTools*: while
8
+ // `react-dom` is being evaluated it looks for `__REACT_DEVTOOLS_GLOBAL_HOOK__`
9
+ // on the global object and registers itself with whatever it finds, once. A
10
+ // hook that arrives after that line has run is a hook no renderer ever sees, so
11
+ // everything below is about one ordering — the hook exists first — and about
12
+ // saying so in a file whose name a person can grep for.
13
+ //
14
+ // # Why this file exists at all, when it worked before it
15
+ //
16
+ // It did work, and by accident. The Fast Refresh preamble calls
17
+ // `injectIntoGlobalHook` (`./refresh-runtime.js`, Meta's runtime as vendored by
18
+ // `@vitejs/plugin-react`), and that function installs a hook when it finds
19
+ // none, because Fast Refresh needs one to decorate. So `uf dev` had a DevTools
20
+ // hook as a side effect of a function whose subject is hot reloading, in a file
21
+ // uf does not own, with nothing anywhere naming DevTools and no test that would
22
+ // notice its absence. The next upgrade of that vendored runtime, or a change to
23
+ // how the preamble is injected, could have taken it away in a diff nobody would
24
+ // read as being about DevTools. See ubugeeei-prod/uf#503.
25
+ //
26
+ // So the hook is installed here, first, deliberately, and `devtools.test.js`
27
+ // beside this package runs this script's own text against a fake window.
28
+ //
29
+ // # Three things DevTools needs, and what carries each
30
+ //
31
+ // Naming them together, because each is provided somewhere else and each is one
32
+ // edit away from being lost:
33
+ //
34
+ // 1. **The hook, before the renderer.** This module, injected by
35
+ // `packages/vite/index.js`'s `transformIndexHtml` as a *classic* script at
36
+ // the top of the head — see [`devtoolsPreamble`] for why classic.
37
+ // 2. **One copy of the renderer.** `resolve.dedupe: ["react", "react-dom"]`
38
+ // in that same file. Two copies of `react-dom` register two renderers, and
39
+ // DevTools shows the tree of whichever one it heard from — which is the
40
+ // shape of the "multiple renderers concurrently rendering" report.
41
+ // 3. **The development build of it.** `mode` is `development`, so Vite
42
+ // resolves React's development export condition, and `uf transform` is
43
+ // called with `development: true` — which is what emits `jsxDEV` and the
44
+ // `_jsxFileName` beside every element. Against a production build DevTools
45
+ // says so and shows a tree with no props, no hooks and no source.
46
+ //
47
+ // # And out of a production build
48
+ //
49
+ // A production build injects none of this: `transformIndexHtml` returns an
50
+ // empty list unless the plugin is serving. That is the half a person can check
51
+ // on the artefact rather than by reading, and
52
+ // `crates/uf_cli/tests/vite.rs`'s `a_build_ships_no_devtools_hook` does.
53
+ //
54
+ // What it checks for is the *assignment* below rather than the name, and the
55
+ // distinction is worth stating here because the obvious test is wrong: React's
56
+ // own production build mentions `__REACT_DEVTOOLS_GLOBAL_HOOK__` twice, because
57
+ // reading that global is how a deployed React application is attachable at all.
58
+ // React reads it; only an installer writes it. So `window.<hook> =` is what
59
+ // must be absent, and it is absent because this function is never called
60
+ // outside a dev server.
61
+
62
+ /**
63
+ * The global React registers itself with.
64
+ *
65
+ * Written once, here, so that every other mention of it in uf — the injected
66
+ * script below, the assertion that a build has none — is this constant rather
67
+ * than a fourth spelling of a name whose whole value is that it matches
68
+ * React's exactly.
69
+ */
70
+ export const DEVTOOLS_HOOK = "__REACT_DEVTOOLS_GLOBAL_HOOK__";
71
+
72
+ /**
73
+ * The script every document loads before anything else in development.
74
+ *
75
+ * # A classic script, not a module
76
+ *
77
+ * Everything else uf injects is `type="module"`, and a module script is
78
+ * deferred: it runs after the document has been parsed, in document order with
79
+ * the other modules. That would still be early enough today, because the client
80
+ * entry is also a module and comes later — but "early enough as long as nobody
81
+ * adds a script above it" is exactly the accident this file exists to end. A
82
+ * classic inline script runs while the parser is on it, so no module, no
83
+ * import, and no `<script src>` a project's own Vite plugin injects can get
84
+ * between this and the renderer.
85
+ *
86
+ * # It never replaces a hook that is already there
87
+ *
88
+ * The DevTools extension installs its hook at `document_start`, which is before
89
+ * any script in the document, so on a machine that has DevTools the branch
90
+ * below is not taken and the extension's hook is what React registers with.
91
+ * Overwriting it would be the one way this file could break the thing it exists
92
+ * to support: the extension holds the connection to the panel, and a stub in
93
+ * its place is a page DevTools can see and never hear from.
94
+ *
95
+ * What is installed when there is nothing to leave alone is the minimum a
96
+ * renderer will register with — `renderers`, `supportsFiber`, `inject` and the
97
+ * three commit callbacks. It reports nothing to anybody: there is no panel, and
98
+ * the point of installing it is that `react-dom` takes the branch where a hook
99
+ * exists, so Fast Refresh has one to decorate and DevTools opened *later* in
100
+ * the same page finds a renderer already registered rather than a page that has
101
+ * to be reloaded. It is the same shape `injectIntoGlobalHook` installs, because
102
+ * it is the same contract; the difference is that this is uf saying so.
103
+ */
104
+ export function devtoolsPreamble() {
105
+ return `(function () {
106
+ if (window.${DEVTOOLS_HOOK} != null) return;
107
+ var nextID = 0;
108
+ window.${DEVTOOLS_HOOK} = {
109
+ renderers: new Map(),
110
+ supportsFiber: true,
111
+ inject: function () { return nextID++; },
112
+ onScheduleFiberRoot: function () {},
113
+ onCommitFiberRoot: function () {},
114
+ onCommitFiberUnmount: function () {},
115
+ };
116
+ })();`;
117
+ }
@@ -0,0 +1,369 @@
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; the hydration-mismatch report beside it is what calls
20
+ // it, and so is `internal/devtools.js`, which reads back after hydration
21
+ // whether React DevTools can attach to this page at all
22
+ // (ubugeeei-prod/uf#503). One channel and not one per feature is the whole
23
+ // point: a second endpoint would be a second thing to notice.
24
+ // * `POST /__uf/vitals` — the five numbers `@uniflowed/web/vitals`
25
+ // measures, posted by `vitalsBeacon()`. In production a project points the
26
+ // beacon at an endpoint of its own; in development there was nothing at
27
+ // the default path, so the one place the numbers are most useful — while
28
+ // you are looking at the page — was the one place they went nowhere.
29
+ //
30
+ // Both end up as the same `diagnostic` event on the driver's control channel
31
+ // (see `./events.js`), which is what gets them uf's own rendering: a severity,
32
+ // a location, and a code frame when the browser had a position to give.
33
+ //
34
+ // # The terminal, and not a browser overlay
35
+ //
36
+ // ubugeeei-prod/uf#557 asked for the vitals to be shown in the overlay. They
37
+ // are shown in the terminal instead, and the later issue that generalised this
38
+ // — #583 — is the argument: a diagnostic that exists only in a browser window
39
+ // has to be noticed by somebody who does not know to look, which is the defect
40
+ // rather than the delivery. Sending these *back* to an overlay would also be
41
+ // circular for the diagnostic half, which arrived from the page in the first
42
+ // place, and an overlay covers the page a performance number is about. What it
43
+ // costs is that a reader watching the browser rather than the terminal sees
44
+ // nothing until they look, which is where every other uf diagnostic already
45
+ // is.
46
+ //
47
+ // # Nothing leaves the machine
48
+ //
49
+ // This module opens no connection. It reads a request that the page on the
50
+ // other end of the dev server's own socket made, writes a line to the terminal
51
+ // that started the dev server, and answers `204`. There is no destination, no
52
+ // third party and nothing to configure, in development or otherwise.
53
+ //
54
+ // # Why the paths are written out here
55
+ //
56
+ // `VITALS_ENDPOINT` in `@uniflowed/web/vitals` and `DIAGNOSTIC_ENDPOINT` in
57
+ // `@uniflowed/router`'s `internal/diagnostics.js` are the same two strings, and
58
+ // they are the contract. They cannot be *imported* here: this module is loaded
59
+ // by Vite before any Flow transform exists, and both of those are Flow. So they
60
+ // are written out, and `packages/vite/dev-channel.test.js` asserts that all
61
+ // four spellings agree — a duplicated constant with a test on it is honest, and
62
+ // one without is how the browser ends up posting to a path nothing serves.
63
+ //
64
+ // # Why `/__uf/`
65
+ //
66
+ // A directory under `app/` whose name begins with `_` is not a route, so no
67
+ // application can put anything at this prefix and nothing here can shadow a
68
+ // path a project wrote. That is what makes it safe as a default destination
69
+ // and available to the dev server.
70
+
71
+ /** Where `@uniflowed/router`'s `reportDiagnostic` posts. */
72
+ export const DIAGNOSTIC_ENDPOINT = "/__uf/diagnostic";
73
+
74
+ /** Where `@uniflowed/web/vitals`'s `vitalsBeacon()` posts by default. */
75
+ export const VITALS_ENDPOINT = "/__uf/vitals";
76
+
77
+ /**
78
+ * The most a report may weigh.
79
+ *
80
+ * A diagnostic is a headline and a few lines of context; a vitals report is
81
+ * five numbers. Neither is close to this, and the ceiling is here because the
82
+ * body arrives from a page — "no unbounded anything" in `docs/security.md`
83
+ * covers a dev server reading a request as much as it covers a production one,
84
+ * and a page with a runaway loop in it must not be able to make `uf dev` grow
85
+ * without bound.
86
+ */
87
+ export const MAX_BODY_BYTES = 64 * 1024;
88
+
89
+ /** The most detail lines one diagnostic prints. */
90
+ const MAX_DETAIL_LINES = 40;
91
+
92
+ /** The most characters any single line of a diagnostic prints. */
93
+ const MAX_LINE_CHARS = 400;
94
+
95
+ /** The most metrics one vitals report is read for; there are five names. */
96
+ const MAX_VITALS = 16;
97
+
98
+ /** The most characters a metric's name or its rating may print as. */
99
+ const MAX_NAME_CHARS = 32;
100
+
101
+ /** The severities the channel accepts, and the words the terminal uses. */
102
+ const SEVERITIES = new Set(["error", "warn", "info"]);
103
+
104
+ /**
105
+ * The connect middleware that answers the channel.
106
+ *
107
+ * Mounted **before** the application middleware, so a request under `/__uf/`
108
+ * never reaches a project's `$middleware.js` or its route table. A guard
109
+ * that ran for a page's own telemetry would be a guard asked a question the
110
+ * application never asks, and one that redirected it would turn a report into
111
+ * a login page.
112
+ *
113
+ * `report` is injected rather than reached for so that this module can be
114
+ * driven without a terminal, a socket or a driver; `internal/events.js`'s
115
+ * `emit` is what the plugin passes.
116
+ *
117
+ * @param {(diagnostic: object) => void} report
118
+ */
119
+ export function createChannelMiddleware(report) {
120
+ return async function channel(request, response, next) {
121
+ const pathname = (request.url ?? "/").split("?")[0];
122
+ const isVitals = pathname === VITALS_ENDPOINT;
123
+ if (!isVitals && pathname !== DIAGNOSTIC_ENDPOINT) {
124
+ next();
125
+ return;
126
+ }
127
+
128
+ // A `GET` on either path is somebody checking whether the dev server has
129
+ // them, and `405` with `Allow` answers that exactly. `404` would have said
130
+ // the opposite of the truth.
131
+ if (request.method !== "POST") {
132
+ response.statusCode = 405;
133
+ response.setHeader("allow", "POST");
134
+ response.end();
135
+ return;
136
+ }
137
+
138
+ let body;
139
+ try {
140
+ body = await readBody(request, MAX_BODY_BYTES);
141
+ } catch {
142
+ // A socket that went away mid-body. There is nothing to report and
143
+ // nobody left to answer.
144
+ response.statusCode = 400;
145
+ response.end();
146
+ return;
147
+ }
148
+ if (body == null) {
149
+ response.statusCode = 413;
150
+ response.end();
151
+ return;
152
+ }
153
+
154
+ let payload = null;
155
+ try {
156
+ payload = JSON.parse(body);
157
+ } catch {
158
+ payload = null;
159
+ }
160
+ const diagnostic = isVitals ? vitalsDiagnostic(payload) : browserDiagnostic(payload);
161
+ if (diagnostic == null) {
162
+ // The body was not the shape this path promises. Refused rather than
163
+ // guessed at: a diagnostic assembled out of a malformed report is a line
164
+ // in somebody's terminal that describes nothing.
165
+ response.statusCode = 400;
166
+ response.end();
167
+ return;
168
+ }
169
+
170
+ // Everything above either answered or handed the request on, so nothing
171
+ // below can leave one hanging — and an exception from `report` is the dev
172
+ // server's own failure rather than the page's, so it goes to Vite's error
173
+ // handler like any other. An `async` connect middleware whose rejection
174
+ // nobody catches is an unhandled rejection, which on a modern Node ends
175
+ // the process: `uf dev` would exit on a malformed telemetry post.
176
+ try {
177
+ report(diagnostic);
178
+ } catch (error) {
179
+ next(error);
180
+ return;
181
+ }
182
+ // No body, and nothing about the machine in the answer. The page posted
183
+ // this and is not owed a reading of it back.
184
+ response.statusCode = 204;
185
+ response.end();
186
+ };
187
+ }
188
+
189
+ /**
190
+ * One diagnostic a browser-side runtime produced, or `null`.
191
+ *
192
+ * Every field is checked and every string is bounded, because all of it is
193
+ * page-authored: a hydration mismatch on a page whose difference is in
194
+ * somebody's comment carries that comment into this terminal. Nothing here is
195
+ * interpreted — the terminal renderer prints text — but a report with a
196
+ * thousand lines in it would still scroll the reason for it off the screen.
197
+ *
198
+ * @param {unknown} payload
199
+ */
200
+ export function browserDiagnostic(payload) {
201
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) return null;
202
+ const message = line(payload.message);
203
+ if (message === "") return null;
204
+
205
+ const severity = SEVERITIES.has(payload.severity) ? payload.severity : "error";
206
+ const diagnostic = { severity, message };
207
+ const origin = line(payload.url);
208
+ if (origin !== "") diagnostic.origin = origin;
209
+ const detail = lines(payload.detail);
210
+ if (detail.length > 0) diagnostic.detail = detail;
211
+ // A position, when the browser had one. It is what turns the status line
212
+ // into a code frame on the other side, and a browser that only knows "this
213
+ // component" rather than "this line" is expected: the frame is the better
214
+ // rendering when it is available and never a requirement.
215
+ const file = line(payload.file);
216
+ if (file !== "" && Number.isInteger(payload.line) && payload.line > 0) {
217
+ diagnostic.file = file;
218
+ diagnostic.line = payload.line;
219
+ if (Number.isInteger(payload.column) && payload.column >= 0) {
220
+ diagnostic.column = payload.column;
221
+ }
222
+ }
223
+ return diagnostic;
224
+ }
225
+
226
+ /**
227
+ * A `VitalsReport` as one diagnostic, or `null` when it carries no metric.
228
+ *
229
+ * One diagnostic per report rather than one per metric, because the beacon
230
+ * already coalesces across a microtask and a page load would otherwise be five
231
+ * separate lines interleaved with whatever else the terminal is saying. The
232
+ * severity is the worst rating in the report, which is the rule the issue asks
233
+ * for: a rating that is not `good` is the interesting one and has to read as
234
+ * one.
235
+ *
236
+ * @param {unknown} payload
237
+ */
238
+ export function vitalsDiagnostic(payload) {
239
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) return null;
240
+ if (!Array.isArray(payload.vitals)) return null;
241
+
242
+ const measured = [];
243
+ for (const vital of payload.vitals.slice(0, MAX_VITALS)) {
244
+ if (vital == null || typeof vital !== "object") continue;
245
+ if (typeof vital.name !== "string" || typeof vital.value !== "number") continue;
246
+ if (!Number.isFinite(vital.value)) continue;
247
+ // The name and the rating are the page's strings, not this module's, even
248
+ // though a beacon written by `@uniflowed/web/vitals` only ever sends the
249
+ // five names and the three ratings. Anything can post here, so they go
250
+ // through the same bounding and control-character scrub as a diagnostic's
251
+ // own text, and a *word* has no business being longer than a word.
252
+ const name = line(vital.name).slice(0, MAX_NAME_CHARS);
253
+ if (name === "") continue;
254
+ const rating = line(vital.rating).slice(0, MAX_NAME_CHARS) || "unknown";
255
+ measured.push({ name, value: vital.value, rating });
256
+ }
257
+ if (measured.length === 0) return null;
258
+
259
+ // Worst first, so the line the reader needs is the line under the headline
260
+ // rather than wherever the browser happened to finish measuring.
261
+ measured.sort((left, right) => severityOf(right.rating) - severityOf(left.rating));
262
+ const worst = measured[0];
263
+ const severity = ratingSeverity(worst.rating);
264
+ const message =
265
+ severity === "info"
266
+ ? `web vitals: ${measured.map((vital) => vital.name).join(", ")} good`
267
+ : `web vitals: ${worst.name} is ${worst.rating} (${formatValue(worst)})`;
268
+
269
+ const diagnostic = {
270
+ severity,
271
+ message,
272
+ detail: measured.map((vital) => `${vital.name} ${formatValue(vital)} — ${vital.rating}`),
273
+ };
274
+ const origin = line(payload.url);
275
+ if (origin !== "") diagnostic.origin = origin;
276
+ return diagnostic;
277
+ }
278
+
279
+ /** How bad a rating is, for ordering; an unknown word sorts as the worst. */
280
+ function severityOf(rating) {
281
+ if (rating === "good") return 0;
282
+ if (rating === "needs-improvement") return 1;
283
+ return 2;
284
+ }
285
+
286
+ /** The channel severity a rating maps to. */
287
+ function ratingSeverity(rating) {
288
+ if (rating === "good") return "info";
289
+ if (rating === "needs-improvement") return "warn";
290
+ return "error";
291
+ }
292
+
293
+ /**
294
+ * A metric's value with its unit.
295
+ *
296
+ * CLS is a unitless layout-shift score and everything else is milliseconds,
297
+ * which is the one thing a reader has to know to act on the number — a `0.24`
298
+ * printed as `0.24 ms` reads as the best result in the report rather than a
299
+ * failing one.
300
+ */
301
+ function formatValue(vital) {
302
+ if (vital.name === "CLS") return String(Math.round(vital.value * 1000) / 1000);
303
+ return `${Math.round(vital.value)} ms`;
304
+ }
305
+
306
+ /** One bounded single-line string, or `""` for anything that is not one. */
307
+ function line(value) {
308
+ if (typeof value !== "string") return "";
309
+ return printable(value).trim();
310
+ }
311
+
312
+ /** A bounded list of bounded lines, from a string or an array of them. */
313
+ function lines(value) {
314
+ const source = typeof value === "string" ? value.split("\n") : value;
315
+ if (!Array.isArray(source)) return [];
316
+ const kept = [];
317
+ for (const entry of source) {
318
+ if (kept.length === MAX_DETAIL_LINES) {
319
+ kept.push("…");
320
+ break;
321
+ }
322
+ if (typeof entry !== "string") continue;
323
+ kept.push(printable(entry));
324
+ }
325
+ return kept;
326
+ }
327
+
328
+ /**
329
+ * One line of page-authored text, safe to write to a terminal and bounded.
330
+ *
331
+ * Every control character becomes a space, `ESC` included, and that is the
332
+ * point rather than tidiness: what is being rendered was written by a page, and
333
+ * a page that could put `ESC [` into a diagnostic could move the cursor,
334
+ * recolour the rest of the session or overwrite the line above its own report.
335
+ * `uf` owns this terminal — see `internal/events.js` — and nothing that arrives
336
+ * over a socket gets to draw on it.
337
+ *
338
+ * A scan rather than a regular expression, per `docs/security.md`'s "no regex
339
+ * on untrusted input": the rule is about backtracking and a character class
340
+ * cannot backtrack, but a loop needs no argument at all and is no longer.
341
+ */
342
+ function printable(value) {
343
+ let text = "";
344
+ for (const character of value.slice(0, MAX_LINE_CHARS)) {
345
+ const code = character.codePointAt(0);
346
+ text += code < 0x20 || code === 0x7f ? " " : character;
347
+ }
348
+ return text;
349
+ }
350
+
351
+ /**
352
+ * The whole request body, or `null` when it is over `limit`.
353
+ *
354
+ * Counted as it arrives rather than trusting `content-length`: the header is
355
+ * the sender's claim and the bytes are the fact, and `sendBeacon` sends
356
+ * neither a length this side should rely on nor a content type worth reading —
357
+ * a string payload goes out as `text/plain`, so the type says nothing about
358
+ * whether the body is the JSON both endpoints document.
359
+ */
360
+ async function readBody(request, limit) {
361
+ let size = 0;
362
+ const chunks = [];
363
+ for await (const chunk of request) {
364
+ size += chunk.length;
365
+ if (size > limit) return null;
366
+ chunks.push(chunk);
367
+ }
368
+ return Buffer.concat(chunks).toString("utf8");
369
+ }
@@ -11,6 +11,7 @@
11
11
  // logger, which is redirected here as well so nothing bypasses the channel.
12
12
 
13
13
  import { createLogger } from "vite";
14
+ import { recordDevEvent } from "./dev-state.js";
14
15
 
15
16
  /**
16
17
  * Emit one event.
@@ -19,6 +20,7 @@ import { createLogger } from "vite";
19
20
  * `done` is on the pipe before the process exits.
20
21
  */
21
22
  export function emit(event, fields = {}) {
23
+ recordDevEvent({ event, ...fields });
22
24
  process.stdout.write(`${JSON.stringify({ event, ...fields })}\n`);
23
25
  }
24
26
 
@@ -54,11 +56,11 @@ export function stripAnsi(text) {
54
56
  /**
55
57
  * Report a page that rendered its error boundary instead of itself.
56
58
  *
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.
59
+ * The document the browser gets is the application's error page, which is what
60
+ * a visitor would see; the exception belongs in the terminal, which is uf's.
61
+ * There is one renderer under `uf dev` and therefore one caller of this — see
62
+ * `../index.js` — where there used to be two middlewares and a message that
63
+ * could be written twice.
62
64
  *
63
65
  * The stack is mapped back onto the Flow source first, so the frames name the
64
66
  * file that was written rather than the one that was compiled.
@@ -67,6 +69,7 @@ export function reportRenderError(server, url, error) {
67
69
  if (error instanceof Error) {
68
70
  server.ssrFixStacktrace(error);
69
71
  }
72
+ emit("diagnostic", { severity: "error", kind: "runtime", origin: url, ...errorEvent(error) });
70
73
  const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
71
74
  server.config.logger.error(`${url} rendered its error boundary\n${stripAnsi(detail)}`);
72
75
  }