@uniflowed/vite 0.0.0-alpha.13 → 0.0.0-alpha.15
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 +436 -149
- package/index.js +217 -93
- package/internal/assets.js +266 -18
- package/internal/devtools.js +117 -0
- package/internal/diagnostics.js +369 -0
- package/internal/events.js +5 -5
- package/internal/routes.js +62 -8
- package/internal/serve.js +39 -15
- package/package.json +11 -3
|
@@ -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 `tests/library/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 `_uf.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
|
@@ -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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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.
|
package/internal/routes.js
CHANGED
|
@@ -547,6 +547,25 @@ export const VIRTUAL = Object.freeze({
|
|
|
547
547
|
* `virtual:uf/server` is generated with the default and always will be: the
|
|
548
548
|
* server renders every route, so its table is the complete one.
|
|
549
549
|
*
|
|
550
|
+
* # `relativeTo`, and the one string in this table a browser can read
|
|
551
|
+
*
|
|
552
|
+
* Every `import()` here is a specifier Vite resolves and rewrites to a chunk
|
|
553
|
+
* URL, so no absolute path survives the build — except `file`, which is a
|
|
554
|
+
* string. It is the route's source path, kept for diagnostics: the middleware
|
|
555
|
+
* table's is what names a module in an error, and `router.js`'s generated
|
|
556
|
+
* types are about the same files.
|
|
557
|
+
*
|
|
558
|
+
* The server's table can hold an absolute path; it is read on the machine that
|
|
559
|
+
* has those files. The browser's cannot, because that table is downloaded:
|
|
560
|
+
* uf's own manual shipped `/home/<user>/…/docs/app/guide/cache/_uf.page.mdx`
|
|
561
|
+
* for each of thirty-four routes to every visitor, which publishes the build
|
|
562
|
+
* machine's layout and its user's name for nothing — the browser has no
|
|
563
|
+
* filesystem to resolve them against and reads them only in a message.
|
|
564
|
+
*
|
|
565
|
+
* So the client call passes the project root and every `file` here is emitted
|
|
566
|
+
* relative to it. Diagnostics keep a path a person can act on — a shorter one
|
|
567
|
+
* — and a deploy stops describing the machine it was built on.
|
|
568
|
+
*
|
|
550
569
|
* @param {{
|
|
551
570
|
* routes: Route[],
|
|
552
571
|
* handlers?: Handler[],
|
|
@@ -554,10 +573,29 @@ export const VIRTUAL = Object.freeze({
|
|
|
554
573
|
* notFound?: NotFoundBoundary[],
|
|
555
574
|
* errors?: ErrorBoundary[],
|
|
556
575
|
* }} table
|
|
557
|
-
* @param {{
|
|
576
|
+
* @param {{
|
|
577
|
+
* shipsPage?: (route: Route) => boolean,
|
|
578
|
+
* relativeTo?: string,
|
|
579
|
+
* }} [options]
|
|
558
580
|
*/
|
|
559
581
|
export function routesModuleSource(table, options = {}) {
|
|
560
582
|
const shipsPage = options.shipsPage ?? (() => true);
|
|
583
|
+
const relativeTo = options.relativeTo ?? null;
|
|
584
|
+
/**
|
|
585
|
+
* A `file` as this table should state it.
|
|
586
|
+
*
|
|
587
|
+
* Relative even when that means leading `..` segments — a module outside the
|
|
588
|
+
* project root is rare and a `../` path still says where it is without
|
|
589
|
+
* saying where the machine is, which is the whole property. Separators are
|
|
590
|
+
* POSIX because this string is read wherever the bundle is opened rather
|
|
591
|
+
* than where it was written.
|
|
592
|
+
*/
|
|
593
|
+
const displayFile = (file) => {
|
|
594
|
+
if (relativeTo == null || file == null) {
|
|
595
|
+
return file;
|
|
596
|
+
}
|
|
597
|
+
return path.relative(relativeTo, file).split(path.sep).join("/");
|
|
598
|
+
};
|
|
561
599
|
const layoutIds = new Map();
|
|
562
600
|
const layoutImports = [];
|
|
563
601
|
const layoutId = (file) => {
|
|
@@ -616,7 +654,7 @@ export function routesModuleSource(table, options = {}) {
|
|
|
616
654
|
path: ${JSON.stringify(route.path)},
|
|
617
655
|
params: ${JSON.stringify(route.params)},
|
|
618
656
|
mdx: ${route.mdx},
|
|
619
|
-
file: ${JSON.stringify(route.page)},
|
|
657
|
+
file: ${JSON.stringify(displayFile(route.page))},
|
|
620
658
|
layouts: [],
|
|
621
659
|
loading: [],
|
|
622
660
|
templates: [],
|
|
@@ -633,7 +671,7 @@ export function routesModuleSource(table, options = {}) {
|
|
|
633
671
|
path: ${JSON.stringify(route.path)},
|
|
634
672
|
params: ${JSON.stringify(route.params)},
|
|
635
673
|
mdx: ${route.mdx},
|
|
636
|
-
file: ${JSON.stringify(route.page)},
|
|
674
|
+
file: ${JSON.stringify(displayFile(route.page))},
|
|
637
675
|
page: () => import(${JSON.stringify(route.page)}),
|
|
638
676
|
layouts: [${layouts.join(", ")}],
|
|
639
677
|
loading: [${loading.join(", ")}],
|
|
@@ -648,7 +686,7 @@ export function routesModuleSource(table, options = {}) {
|
|
|
648
686
|
const SYNTHESISED = JSON.stringify("@uniflowed/router");
|
|
649
687
|
const boundaryModule = (file) =>
|
|
650
688
|
file == null ? "null" : `() => import(${JSON.stringify(file)})`;
|
|
651
|
-
const boundaryFile = (file) => (file == null ? SYNTHESISED : JSON.stringify(file));
|
|
689
|
+
const boundaryFile = (file) => (file == null ? SYNTHESISED : JSON.stringify(displayFile(file)));
|
|
652
690
|
|
|
653
691
|
// A list, because a not-found is a segment file: every directory may declare
|
|
654
692
|
// one and the router takes the nearest above the path. `layoutId` is the
|
|
@@ -684,7 +722,7 @@ export function routesModuleSource(table, options = {}) {
|
|
|
684
722
|
(handler) => ` {
|
|
685
723
|
path: ${JSON.stringify(handler.path)},
|
|
686
724
|
params: ${JSON.stringify(handler.params)},
|
|
687
|
-
file: ${JSON.stringify(handler.module)},
|
|
725
|
+
file: ${JSON.stringify(displayFile(handler.module))},
|
|
688
726
|
load: () => import(${JSON.stringify(handler.module)}),
|
|
689
727
|
}`,
|
|
690
728
|
);
|
|
@@ -697,7 +735,7 @@ export function routesModuleSource(table, options = {}) {
|
|
|
697
735
|
const middlewareEntries = (table.middleware ?? []).map(
|
|
698
736
|
(entry) => ` {
|
|
699
737
|
path: ${JSON.stringify(entry.path)},
|
|
700
|
-
file: ${JSON.stringify(entry.module)},
|
|
738
|
+
file: ${JSON.stringify(displayFile(entry.module))},
|
|
701
739
|
load: () => import(${JSON.stringify(entry.module)}),
|
|
702
740
|
}`,
|
|
703
741
|
);
|
|
@@ -752,12 +790,28 @@ export default routes;
|
|
|
752
790
|
* The current route's modules are loaded *before* hydration so the first
|
|
753
791
|
* render is synchronous and matches the server's HTML; a lazy import during
|
|
754
792
|
* hydration would suspend and React would fall back to a client render.
|
|
793
|
+
*
|
|
794
|
+
* # Strict Mode is a generated constant, not a runtime check
|
|
795
|
+
*
|
|
796
|
+
* `strictMode` is written into this module as a literal, so a production build
|
|
797
|
+
* gets `hydrate({ … })` with the argument absent and Rollup has nothing to
|
|
798
|
+
* decide. It would have been shorter to have `hydrate` read `import.meta.hot`
|
|
799
|
+
* — the way `client.js` gates the hydration reporter — and that would have been
|
|
800
|
+
* one signal answering two questions: `uf.config.js` can turn Strict Mode off
|
|
801
|
+
* (ubugeeei-prod/uf#516) and `import.meta.hot` cannot be told about it. A
|
|
802
|
+
* project that sets `app.react.strictMode: false` gets a dev server that
|
|
803
|
+
* hydrates the way its deployment does, which is the whole of the escape
|
|
804
|
+
* hatch.
|
|
805
|
+
*
|
|
806
|
+
* @param {string} appEntry the project's `app.js`, as an import specifier
|
|
807
|
+
* @param {{ strictMode?: boolean }} [options]
|
|
755
808
|
*/
|
|
756
|
-
export function clientModuleSource(appEntry) {
|
|
809
|
+
export function clientModuleSource(appEntry, options = {}) {
|
|
810
|
+
const strictMode = options.strictMode === true ? ", strictMode: true" : "";
|
|
757
811
|
return `import { hydrate } from "@uniflowed/router/client";
|
|
758
812
|
import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
|
|
759
813
|
import App from ${JSON.stringify(appEntry)};
|
|
760
|
-
hydrate({ App, routes, notFound, errors });
|
|
814
|
+
hydrate({ App, routes, notFound, errors${strictMode} });
|
|
761
815
|
`;
|
|
762
816
|
}
|
|
763
817
|
|
package/internal/serve.js
CHANGED
|
@@ -245,21 +245,7 @@ export function assetsFromManifest(manifest) {
|
|
|
245
245
|
* @param {() => Promise<mixed>} body
|
|
246
246
|
*/
|
|
247
247
|
export async function withRequest(entry, request, body) {
|
|
248
|
-
const
|
|
249
|
-
const { run, settle } = lifecycle;
|
|
250
|
-
// What this host can do, put on the request the way `createFetchHandler`
|
|
251
|
-
// puts it on the one it owns. `uf dev` and `uf build --compile` reach a
|
|
252
|
-
// route handler without going through that function, and a handler that
|
|
253
|
-
// streams events or queues work has to get the same answer from all four
|
|
254
|
-
// front doors — a capability that is present under `uf start` and absent
|
|
255
|
-
// under `uf dev` is the difference this whole seam exists to remove.
|
|
256
|
-
//
|
|
257
|
-
// `nodeCapabilities`, because both of those *are* a Node process with a
|
|
258
|
-
// socket: a body reaches the client as it is written, and the process is
|
|
259
|
-
// still there afterwards. Neither passes an upgrader or a queue, because uf
|
|
260
|
-
// defines both and implements neither.
|
|
261
|
-
const { nodeCapabilities } = await deployment();
|
|
262
|
-
lifecycle.context.capabilities ??= nodeCapabilities();
|
|
248
|
+
const { run, settle } = await beginRequest(entry, request);
|
|
263
249
|
try {
|
|
264
250
|
return await run(body);
|
|
265
251
|
} finally {
|
|
@@ -267,6 +253,44 @@ export async function withRequest(entry, request, body) {
|
|
|
267
253
|
}
|
|
268
254
|
}
|
|
269
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Begin a request on this host, with what this host can do already on it.
|
|
258
|
+
*
|
|
259
|
+
* The half of [`withRequest`] that a caller which may *not* answer needs.
|
|
260
|
+
* `uf dev` runs the application's middleware, its action endpoint and its
|
|
261
|
+
* dispatcher for every request, and hands the ones none of them claimed back
|
|
262
|
+
* to Vite's chain — at which point the response is written somewhere this
|
|
263
|
+
* module cannot see, so settling has to wait for the socket rather than for a
|
|
264
|
+
* `finally` here. A caller that always answers should use [`withRequest`] and
|
|
265
|
+
* not think about it.
|
|
266
|
+
*
|
|
267
|
+
* `entry.beginRequest` and not an import: the request lives in an
|
|
268
|
+
* `AsyncLocalStorage` belonging to one copy of `@uniflowed/server`, and the
|
|
269
|
+
* copy that matters is the one inside the application bundle. See
|
|
270
|
+
* `serverModuleSource` in `./routes.js`.
|
|
271
|
+
*
|
|
272
|
+
* What this host can do is put on the request the way `createFetchHandler`
|
|
273
|
+
* puts it on the one it owns. `uf dev` and `uf build --compile` reach a route
|
|
274
|
+
* handler without going through that function, and a handler that streams
|
|
275
|
+
* events or queues work has to get the same answer from all four front doors —
|
|
276
|
+
* a capability that is present under `uf start` and absent under `uf dev` is
|
|
277
|
+
* the difference this whole seam exists to remove.
|
|
278
|
+
*
|
|
279
|
+
* `nodeCapabilities`, because both of those *are* a Node process with a
|
|
280
|
+
* socket: a body reaches the client as it is written, and the process is still
|
|
281
|
+
* there afterwards. Neither passes an upgrader or a queue, because uf defines
|
|
282
|
+
* both and implements neither.
|
|
283
|
+
*
|
|
284
|
+
* @param {{beginRequest: (request: Request) => {context: object, run: <T>(body: () => Promise<T>) => Promise<T>, settle: () => Promise<void>}}} entry
|
|
285
|
+
* @param {Request} request
|
|
286
|
+
*/
|
|
287
|
+
export async function beginRequest(entry, request) {
|
|
288
|
+
const lifecycle = entry.beginRequest(request);
|
|
289
|
+
const { nodeCapabilities } = await deployment();
|
|
290
|
+
lifecycle.context.capabilities ??= nodeCapabilities();
|
|
291
|
+
return lifecycle;
|
|
292
|
+
}
|
|
293
|
+
|
|
270
294
|
/**
|
|
271
295
|
* The application half: route handlers, then rendering.
|
|
272
296
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/vite",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.15",
|
|
4
4
|
"description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
11
|
"directory": "packages/vite"
|
|
12
12
|
},
|
|
13
|
+
"uf": {
|
|
14
|
+
"builder": {
|
|
15
|
+
"driver": "./driver.js",
|
|
16
|
+
"preload": {
|
|
17
|
+
"bun": "@uniflowed/host/bun-preload"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
13
21
|
"exports": {
|
|
14
22
|
".": "./index.js",
|
|
15
23
|
"./driver": "./driver.js",
|
|
@@ -25,8 +33,8 @@
|
|
|
25
33
|
"dependencies": {
|
|
26
34
|
"@mdx-js/rollup": "^3.1.1",
|
|
27
35
|
"@shikijs/rehype": "^3.23.0",
|
|
28
|
-
"@uniflowed/host": "0.0.0-alpha.
|
|
29
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
36
|
+
"@uniflowed/host": "0.0.0-alpha.15",
|
|
37
|
+
"@uniflowed/server": "0.0.0-alpha.15",
|
|
30
38
|
"rehype-slug": "^6.0.0",
|
|
31
39
|
"remark-frontmatter": "^5.0.0",
|
|
32
40
|
"remark-gfm": "^4.0.1",
|