@uniflowed/vite 0.0.0-alpha.6 → 0.0.0-alpha.7
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 +349 -118
- package/index.js +177 -8
- package/internal/events.js +20 -0
- package/internal/routes.js +112 -22
- package/internal/serve.js +386 -0
- package/package.json +2 -2
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: executed by the host that serves a build.
|
|
4
|
+
//
|
|
5
|
+
// Serving what `uf build` wrote — one request handler, behind two front doors.
|
|
6
|
+
//
|
|
7
|
+
// `uf build` writes three things and, until this module existed, nothing could
|
|
8
|
+
// answer a request with any of them: a client bundle and prerendered HTML in
|
|
9
|
+
// `dist/`, and a server bundle in `.uf/build/server/server.js` that exports
|
|
10
|
+
// `render`, `dispatch`, `routes` and `notFound`. The prerendered half could be
|
|
11
|
+
// put on a static host; the other half could only be reached from `uf dev`, so
|
|
12
|
+
// a route handler and a route with parameters and no `generateStaticParams`
|
|
13
|
+
// worked in development and did not exist in a build.
|
|
14
|
+
//
|
|
15
|
+
// `uf preview` and `uf start` are the two front doors, and they share
|
|
16
|
+
// everything below on purpose. A preview whose answers differ from the
|
|
17
|
+
// production server's is worse than no preview, because it is checked and
|
|
18
|
+
// believed. The difference between the two commands is which socket the
|
|
19
|
+
// handler is bolted to, not what it decides:
|
|
20
|
+
//
|
|
21
|
+
// preview — Vite's own preview server, with this handler behind its static
|
|
22
|
+
// middleware, so `vite.preview.proxy`, `vite.preview.https`,
|
|
23
|
+
// `headers` and `cors` are in effect and what is being checked is
|
|
24
|
+
// the build *as Vite serves it*.
|
|
25
|
+
// start — `node:http`, with no bundler in the process, because a host
|
|
26
|
+
// running a production build should not need Vite installed to
|
|
27
|
+
// answer a request.
|
|
28
|
+
//
|
|
29
|
+
// # Why this is JavaScript
|
|
30
|
+
//
|
|
31
|
+
// The rest of uf's hot paths are Rust, and this one deliberately is not: it
|
|
32
|
+
// runs in the deployed application rather than in the build, and
|
|
33
|
+
// `ubugeeei-redundancy.md` is explicit that a deployment must not inherit a
|
|
34
|
+
// native dependency from the toolchain that produced it. An edge or serverless
|
|
35
|
+
// target that cannot run a Rust binary still has to be able to run this.
|
|
36
|
+
//
|
|
37
|
+
// # The seam the adapters need
|
|
38
|
+
//
|
|
39
|
+
// [`createApplicationHandler`] takes a `Request` and returns a `Response` and
|
|
40
|
+
// touches no filesystem, so it is the part that ports to a worker unchanged.
|
|
41
|
+
// [`createStaticHandler`] reads files and is therefore host-specific, which is
|
|
42
|
+
// exactly the split a deploy adapter has to make: on a CDN-backed target the
|
|
43
|
+
// static half is not the application's job at all.
|
|
44
|
+
|
|
45
|
+
import { createReadStream } from "node:fs";
|
|
46
|
+
import { readFile, stat } from "node:fs/promises";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
import { Readable } from "node:stream";
|
|
49
|
+
import { pathToFileURL } from "node:url";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Content types for what a uf build emits.
|
|
53
|
+
*
|
|
54
|
+
* A closed table rather than a dependency, and deliberately short: every entry
|
|
55
|
+
* is an extension `uf build` actually writes or a project actually puts in
|
|
56
|
+
* `public/`. Anything else is `application/octet-stream`, which a browser
|
|
57
|
+
* downloads rather than executes — the safe answer for a file whose type we do
|
|
58
|
+
* not know, and the reason this is not a guess based on the bytes.
|
|
59
|
+
*/
|
|
60
|
+
const CONTENT_TYPES = Object.freeze({
|
|
61
|
+
".avif": "image/avif",
|
|
62
|
+
".css": "text/css; charset=utf-8",
|
|
63
|
+
".gif": "image/gif",
|
|
64
|
+
".html": "text/html; charset=utf-8",
|
|
65
|
+
".ico": "image/x-icon",
|
|
66
|
+
".jpeg": "image/jpeg",
|
|
67
|
+
".jpg": "image/jpeg",
|
|
68
|
+
".js": "text/javascript; charset=utf-8",
|
|
69
|
+
".json": "application/json; charset=utf-8",
|
|
70
|
+
".map": "application/json; charset=utf-8",
|
|
71
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
72
|
+
".png": "image/png",
|
|
73
|
+
".svg": "image/svg+xml",
|
|
74
|
+
".txt": "text/plain; charset=utf-8",
|
|
75
|
+
".webmanifest": "application/manifest+json",
|
|
76
|
+
".webp": "image/webp",
|
|
77
|
+
".woff": "font/woff",
|
|
78
|
+
".woff2": "font/woff2",
|
|
79
|
+
".xml": "application/xml; charset=utf-8",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Everything a served build consists of.
|
|
84
|
+
*
|
|
85
|
+
* Read once at startup rather than per request: the manifest does not change
|
|
86
|
+
* while the server runs, and importing the server bundle again per request
|
|
87
|
+
* would re-evaluate every module in the application.
|
|
88
|
+
*
|
|
89
|
+
* @param {{root: string, outDir: string, serverDir: string}} build
|
|
90
|
+
*/
|
|
91
|
+
export async function loadBuild({ root, outDir, serverDir }) {
|
|
92
|
+
const distDir = path.resolve(root, outDir);
|
|
93
|
+
const entryFile = path.join(path.resolve(root, serverDir), "server.js");
|
|
94
|
+
|
|
95
|
+
// Named separately, because the two failures have different fixes and a
|
|
96
|
+
// combined "run uf build" would be wrong for one of them: a `dist/` with no
|
|
97
|
+
// server bundle beside it is what a `.uf/` that was cleaned looks like.
|
|
98
|
+
await readable(entryFile, `the server bundle is missing at ${entryFile}`);
|
|
99
|
+
const manifestFile = path.join(distDir, ".vite", "manifest.json");
|
|
100
|
+
await readable(manifestFile, `the client manifest is missing at ${manifestFile}`);
|
|
101
|
+
|
|
102
|
+
const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
103
|
+
const entry = await import(pathToFileURL(entryFile).href);
|
|
104
|
+
return { entry, assets: assetsFromManifest(manifest), distDir };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readable(file, message) {
|
|
108
|
+
try {
|
|
109
|
+
await stat(file);
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error(`uf: ${message}; run \`uf build\` first`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The tags a rendered document needs, from the client build's manifest.
|
|
117
|
+
*
|
|
118
|
+
* Two walks over the manifest, because the two answers are different. A
|
|
119
|
+
* `modulepreload` is worth emitting only for a chunk this document will
|
|
120
|
+
* certainly load, which is the entry's *static* imports. A stylesheet has to
|
|
121
|
+
* be emitted for anything the page might render, and the router loads every
|
|
122
|
+
* route module dynamically — so a stylesheet imported by a layout is reached
|
|
123
|
+
* through `dynamicImports` and through nothing else. Following only the static
|
|
124
|
+
* graph, as this did, meant a layout could import a stylesheet and the built
|
|
125
|
+
* HTML would silently ship without it.
|
|
126
|
+
*
|
|
127
|
+
* The cost is that a project with per-route stylesheets links all of them on
|
|
128
|
+
* every page. Narrowing that needs the route table to say which chunk each
|
|
129
|
+
* route came from, which the manifest alone cannot tell us.
|
|
130
|
+
*
|
|
131
|
+
* The entry is found by its `isEntry` flag rather than by key, because a
|
|
132
|
+
* virtual module's manifest key is an implementation detail of the bundler.
|
|
133
|
+
*/
|
|
134
|
+
export function assetsFromManifest(manifest) {
|
|
135
|
+
const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
|
|
136
|
+
if (entry == null) throw new Error("uf: the client manifest has no entry chunk");
|
|
137
|
+
|
|
138
|
+
const styles = new Set(entry.css ?? []);
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const collectStyles = (chunk) => {
|
|
141
|
+
for (const imported of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) {
|
|
142
|
+
if (seen.has(imported)) continue;
|
|
143
|
+
seen.add(imported);
|
|
144
|
+
const dependency = manifest[imported];
|
|
145
|
+
if (dependency == null) continue;
|
|
146
|
+
for (const css of dependency.css ?? []) styles.add(css);
|
|
147
|
+
collectStyles(dependency);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
collectStyles(entry);
|
|
151
|
+
|
|
152
|
+
const preloads = new Set();
|
|
153
|
+
const collectPreloads = (chunk) => {
|
|
154
|
+
for (const imported of chunk.imports ?? []) {
|
|
155
|
+
const dependency = manifest[imported];
|
|
156
|
+
if (dependency == null || preloads.has(dependency.file)) continue;
|
|
157
|
+
preloads.add(dependency.file);
|
|
158
|
+
collectPreloads(dependency);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
collectPreloads(entry);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
scripts: [`/${entry.file}`],
|
|
165
|
+
styles: [...styles].map((file) => `/${file}`),
|
|
166
|
+
preloads: [...preloads].map((file) => `/${file}`),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The application half: route handlers, then rendering.
|
|
172
|
+
*
|
|
173
|
+
* Touches no filesystem and holds no Node types, so this is the function a
|
|
174
|
+
* deploy adapter for a worker or a serverless function wraps. Returns `null`
|
|
175
|
+
* for nothing, ever — a request that matches no handler and no route is a
|
|
176
|
+
* rendered 404, because the renderer is what knows what the project's
|
|
177
|
+
* `_uf.not-found` page says.
|
|
178
|
+
*
|
|
179
|
+
* The order is the dev server's, and has to stay the dev server's: handlers
|
|
180
|
+
* first and for every method, because a handler is the only thing that can
|
|
181
|
+
* answer a `POST` and it may also answer a `GET` for a path that has no page.
|
|
182
|
+
* A page cannot answer a `POST`, so a non-navigation that no handler claimed
|
|
183
|
+
* is a 404 rather than a rendered page with a 200.
|
|
184
|
+
*
|
|
185
|
+
* @param {{entry: object, assets: object}} build
|
|
186
|
+
*/
|
|
187
|
+
export function createApplicationHandler({ entry, assets }) {
|
|
188
|
+
return async function handle(request) {
|
|
189
|
+
const handled = await entry.dispatch(request);
|
|
190
|
+
if (handled != null) return handled;
|
|
191
|
+
|
|
192
|
+
const method = request.method.toUpperCase();
|
|
193
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
194
|
+
return new Response(null, { status: 404 });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const url = new URL(request.url);
|
|
198
|
+
const result = await entry.render(url.pathname + url.search, assets);
|
|
199
|
+
const headers = new Headers(result.headers ?? {});
|
|
200
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
201
|
+
// A `HEAD` gets the status and the headers and no body, which is what the
|
|
202
|
+
// renderer cannot know to do for itself.
|
|
203
|
+
return new Response(method === "HEAD" ? null : result.html, {
|
|
204
|
+
status: result.status ?? 200,
|
|
205
|
+
headers,
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The static half: a file under `root`, or `null` for the caller to carry on.
|
|
212
|
+
*
|
|
213
|
+
* `GET` and `HEAD` only. A `POST` to a path that happens to have a file under
|
|
214
|
+
* it belongs to a route handler, and answering it with the file's bytes would
|
|
215
|
+
* be the same mistake as rendering a page for it.
|
|
216
|
+
*
|
|
217
|
+
* # The path is checked once, after it is resolved
|
|
218
|
+
*
|
|
219
|
+
* `docs/security.md` rule 2: never authorize against a raw request string or a
|
|
220
|
+
* partially decoded path. The pathname is decoded first, then resolved against
|
|
221
|
+
* the root, and *then* checked to be inside it — so `%2e%2e%2f`, a backslash
|
|
222
|
+
* on Windows, and a symlinked directory all reduce to the same question, asked
|
|
223
|
+
* once, of the value that is actually opened.
|
|
224
|
+
*/
|
|
225
|
+
export function createStaticHandler({ root }) {
|
|
226
|
+
const distDir = path.resolve(root);
|
|
227
|
+
|
|
228
|
+
return async function serveStatic(request) {
|
|
229
|
+
const method = request.method.toUpperCase();
|
|
230
|
+
if (method !== "GET" && method !== "HEAD") return null;
|
|
231
|
+
|
|
232
|
+
const pathname = decodePathname(new URL(request.url).pathname);
|
|
233
|
+
if (pathname == null) return null;
|
|
234
|
+
|
|
235
|
+
const resolved = path.resolve(distDir, `.${pathname}`);
|
|
236
|
+
if (resolved !== distDir && !resolved.startsWith(distDir + path.sep)) return null;
|
|
237
|
+
|
|
238
|
+
// `/guide/` and `/guide` are the same prerendered document, and neither
|
|
239
|
+
// spelling is the one a person types. `<path>.html` is last because a
|
|
240
|
+
// build writes `guide/index.html`, and only a hand-placed file in
|
|
241
|
+
// `public/` is ever `guide.html`.
|
|
242
|
+
const candidates = pathname.endsWith("/")
|
|
243
|
+
? [path.join(resolved, "index.html")]
|
|
244
|
+
: [resolved, path.join(resolved, "index.html"), `${resolved}.html`];
|
|
245
|
+
|
|
246
|
+
for (const candidate of candidates) {
|
|
247
|
+
const info = await statFile(candidate);
|
|
248
|
+
if (info == null || !info.isFile()) continue;
|
|
249
|
+
const headers = {
|
|
250
|
+
"content-type":
|
|
251
|
+
CONTENT_TYPES[path.extname(candidate).toLowerCase()] ?? "application/octet-stream",
|
|
252
|
+
"content-length": String(info.size),
|
|
253
|
+
};
|
|
254
|
+
if (method === "HEAD") return new Response(null, { headers });
|
|
255
|
+
// Streamed rather than read into memory, so serving a large asset costs
|
|
256
|
+
// a buffer rather than the file.
|
|
257
|
+
return new Response(Readable.toWeb(createReadStream(candidate)), { headers });
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Static files, then the application: the whole of what a built uf app serves.
|
|
265
|
+
*
|
|
266
|
+
* Static first, and that ordering is a compatibility requirement rather than a
|
|
267
|
+
* preference. Vite's preview server runs its own file middleware before
|
|
268
|
+
* anything added afterwards can see the request, so `uf preview` serves a file
|
|
269
|
+
* first whether or not this agrees — and `uf start` disagreeing would mean a
|
|
270
|
+
* project whose handler path collides with a file in `public/` behaves one way
|
|
271
|
+
* when it is checked and the other way when it is deployed.
|
|
272
|
+
*
|
|
273
|
+
* @param {{entry: object, assets: object, distDir: string}} build
|
|
274
|
+
*/
|
|
275
|
+
export function createServeHandler({ entry, assets, distDir }) {
|
|
276
|
+
const serveStatic = createStaticHandler({ root: distDir });
|
|
277
|
+
const application = createApplicationHandler({ entry, assets });
|
|
278
|
+
return async function handle(request) {
|
|
279
|
+
return (await serveStatic(request)) ?? (await application(request));
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function decodePathname(pathname) {
|
|
284
|
+
try {
|
|
285
|
+
const decoded = decodeURIComponent(pathname);
|
|
286
|
+
// A NUL truncates the name every C-level `open` sees, so a path holding
|
|
287
|
+
// one is refused rather than normalised into something shorter.
|
|
288
|
+
return decoded.includes("\0") ? null : decoded;
|
|
289
|
+
} catch {
|
|
290
|
+
// A percent escape that is not one. There is no file behind it.
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function statFile(file) {
|
|
296
|
+
try {
|
|
297
|
+
return await stat(file);
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A `Request`/`Response` handler as a Node request listener.
|
|
305
|
+
*
|
|
306
|
+
* The handler contract is the platform's, so this adapter belongs here rather
|
|
307
|
+
* than in every host that wants to run one — `uf dev`'s middleware, `uf
|
|
308
|
+
* preview`'s, and `uf start`'s own server all reach for the same two halves.
|
|
309
|
+
*
|
|
310
|
+
* A handler that throws is answered with a bare 500 and reported on stderr:
|
|
311
|
+
* the body must not carry the stack, because the body goes to whoever asked,
|
|
312
|
+
* and stderr is where the operator is already looking. It is not an event on
|
|
313
|
+
* stdout because a request failing is the application's news, not the driver's
|
|
314
|
+
* — the driver's stdout says what the *server* is doing.
|
|
315
|
+
*/
|
|
316
|
+
export function nodeListener(handle) {
|
|
317
|
+
return async function listener(incoming, outgoing) {
|
|
318
|
+
try {
|
|
319
|
+
await send(outgoing, await handle(await toRequest(incoming)));
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error(error);
|
|
322
|
+
if (outgoing.headersSent) {
|
|
323
|
+
outgoing.destroy();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
outgoing.statusCode = 500;
|
|
327
|
+
outgoing.setHeader("content-type", "text/plain; charset=utf-8");
|
|
328
|
+
outgoing.end("500 Internal Server Error\n");
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A Node request as a `Request`.
|
|
335
|
+
*
|
|
336
|
+
* The body is read as a stream where the host supports it, because a handler
|
|
337
|
+
* that accepts an upload should not need the whole thing buffered before it
|
|
338
|
+
* starts.
|
|
339
|
+
*/
|
|
340
|
+
export async function toRequest(incoming, config) {
|
|
341
|
+
const host = incoming.headers.host ?? "localhost";
|
|
342
|
+
const protocol = config?.server?.https == null ? "http" : "https";
|
|
343
|
+
const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
|
|
344
|
+
|
|
345
|
+
const headers = new Headers();
|
|
346
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
347
|
+
if (value == null) continue;
|
|
348
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
349
|
+
headers.append(name, entry);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const method = (incoming.method ?? "GET").toUpperCase();
|
|
354
|
+
const init = { method, headers };
|
|
355
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
356
|
+
// `duplex` is required by the specification whenever a body is a stream,
|
|
357
|
+
// and Node throws without it.
|
|
358
|
+
init.body = incoming;
|
|
359
|
+
init.duplex = "half";
|
|
360
|
+
}
|
|
361
|
+
return new Request(url, init);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Write a `Response` to a Node response. */
|
|
365
|
+
export async function send(outgoing, result) {
|
|
366
|
+
outgoing.statusCode = result.status;
|
|
367
|
+
if (result.statusText !== "") {
|
|
368
|
+
outgoing.statusMessage = result.statusText;
|
|
369
|
+
}
|
|
370
|
+
for (const [name, value] of result.headers) {
|
|
371
|
+
outgoing.setHeader(name, value);
|
|
372
|
+
}
|
|
373
|
+
if (result.body == null) {
|
|
374
|
+
outgoing.end();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Streamed rather than buffered, so a handler returning a large or
|
|
378
|
+
// open-ended body is not read into memory first.
|
|
379
|
+
const reader = result.body.getReader();
|
|
380
|
+
while (true) {
|
|
381
|
+
const { done, value } = await reader.read();
|
|
382
|
+
if (done) break;
|
|
383
|
+
outgoing.write(value);
|
|
384
|
+
}
|
|
385
|
+
outgoing.end();
|
|
386
|
+
}
|
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.7",
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@mdx-js/rollup": "^3.1.1",
|
|
27
27
|
"@shikijs/rehype": "^3.23.0",
|
|
28
|
-
"@uniflowed/host": "0.0.0-alpha.
|
|
28
|
+
"@uniflowed/host": "0.0.0-alpha.7",
|
|
29
29
|
"rehype-slug": "^6.0.0",
|
|
30
30
|
"remark-frontmatter": "^5.0.0",
|
|
31
31
|
"remark-gfm": "^4.0.1",
|