@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.
- package/driver.js +1855 -190
- package/index.js +1013 -124
- package/internal/a11y-runtime.js +234 -0
- package/internal/a11y.js +102 -0
- package/internal/assets.js +366 -18
- package/internal/barrel-imports.js +459 -0
- package/internal/compile-assets.js +67 -0
- package/internal/config.js +30 -3
- package/internal/dev-state.js +161 -0
- package/internal/devtools.js +117 -0
- package/internal/diagnostics.js +369 -0
- package/internal/events.js +8 -5
- package/internal/flight.js +803 -0
- package/internal/flow-keywords.js +1 -1
- package/internal/frontmatter.js +33 -0
- package/internal/http.js +21 -2
- package/internal/image-endpoint.js +118 -0
- package/internal/instrumentation.js +23 -0
- package/internal/module-graph.js +134 -0
- package/internal/native-web.js +26 -0
- package/internal/openapi.js +218 -0
- package/internal/relay.js +74 -0
- package/internal/routes.js +1294 -81
- package/internal/rsc.js +394 -11
- package/internal/serve.js +583 -29
- package/internal/server-components.js +109 -0
- package/internal/worker-builtins.js +170 -0
- package/package.json +28 -6
|
@@ -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
|
+
}
|
package/internal/events.js
CHANGED
|
@@ -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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
}
|