@uniflowed/vite 0.0.0-alpha.8 → 0.0.0-alpha.9

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 CHANGED
@@ -5,14 +5,20 @@
5
5
  // The driver `uf dev`, `uf build`, `uf build --compile`, `uf preview` and
6
6
  // `uf start` spawn.
7
7
  //
8
- // <host> driver.js dev --root <dir> [--host <h>] [--port <n>] [--strict-port]
9
- // <host> driver.js build --root <dir> [--out-dir <dir>] [--mode <m>]
10
- // <host> driver.js compile --root <dir> [--out-dir <dir>] --assets <file> --bundle <dir>
11
- // <host> driver.js deploy --root <dir> [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
12
- // <host> driver.js preview --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
8
+ // <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
9
+ // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>]
10
+ // <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
11
+ // <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
12
+ // <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
13
13
  // <host> driver.js start --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
14
14
  // <host> driver.js config --root <dir>
15
15
  //
16
+ // `--mode` is what `uf` resolved from `--mode`, `.uniflowed/profile` and
17
+ // `env.active`; it is Vite's mode, so it is `import.meta.env.MODE`. The `.env`
18
+ // files it selected have already been read, by `uf`, into this process's
19
+ // environment — see `viteConfig` below and `crates/uf_config/src/env_files.rs`.
20
+ // `start` has no Vite in it and therefore no mode.
21
+ //
16
22
  // `uf` in Rust owns the terminal; this process owns Vite. They talk over
17
23
  // stdout, one JSON event per line (see `./internal/events.js`), and the driver
18
24
  // exits when its stdin closes so it cannot outlive the command that started
@@ -107,12 +113,23 @@ async function viteConfig(config, mode) {
107
113
  // merely passes on — `allowedHosts` gates binding a routable address, and
108
114
  // `manifest` is how the prerender finds its assets.
109
115
  //
110
- // `envDir: false` rather than `envFile: false`: Vite 8 deprecated the second
111
- // spelling and prints a line saying so on every dev server and every build,
112
- // twice in the docs site's. A project turns the loader back on with
113
- // `vite: { envDir: "." }`, which is the default directory the project's own
114
- // configuration is merged over this one, so it wins. See #259 for why uf
115
- // switches it off at all.
116
+ // `envDir: false` turns off Vite's *file* loading, and only that. uf reads
117
+ // the `.env` cascade itself, in Rust, before this process starts one
118
+ // parser, one precedence, one answer for `uf dev`, `uf build`, `uf start`,
119
+ // `uf test` and `uf run` and sets what it read in this process's
120
+ // environment. Vite's `loadEnv` still runs with `envDir: false` and still
121
+ // picks every `envPrefix`-matching name out of `process.env`, so the client
122
+ // half is Vite's own, unchanged: the prefixed subset becomes
123
+ // `import.meta.env.*` in the browser bundle and nothing else does. See
124
+ // `crates/uf_config/src/env_files.rs`, `docs/app/guide/env` and #259.
125
+ //
126
+ // A project that would rather Vite read the files can still say
127
+ // `vite: { envDir: "." }` — its own configuration is merged over this one —
128
+ // and then both parsers run, uf's answer still standing. `loadEnv` takes the
129
+ // prefixed names out of the files it read and then copies every prefixed name
130
+ // in `process.env` over the top, and uf put its own there before this process
131
+ // started; so the second parser adds prefixed names uf did not set and
132
+ // changes none that it did.
116
133
  const generated = {
117
134
  root,
118
135
  configFile: false,
@@ -189,7 +206,10 @@ async function viteConfig(config, mode) {
189
206
  async function dev() {
190
207
  const { createServer } = await import("vite");
191
208
  const config = await loadConfig();
192
- const inline = await viteConfig(config, "development");
209
+ // The mode is uf's to decide, not this file's: `uf dev` resolves `--mode`,
210
+ // the profile `uf env use` wrote and `env.active` before it starts anything,
211
+ // and always passes the answer. The fallback is for a driver started by hand.
212
+ const inline = await viteConfig(config, argument("--mode") ?? "development");
193
213
  const server = await createServer({ ...inline, appType: "custom" });
194
214
 
195
215
  // In dev the browser loads the client entry from Vite, not from a manifest;
@@ -354,7 +374,7 @@ function watchSources(server) {
354
374
  async function preview() {
355
375
  const { preview: startPreview } = await import("vite");
356
376
  const config = await loadConfig();
357
- const inline = await viteConfig(config, "production");
377
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
358
378
  const build = await loadBuild({
359
379
  root,
360
380
  outDir: inline.build.outDir,
@@ -707,14 +727,65 @@ async function compile() {
707
727
  process.exit(0);
708
728
  }
709
729
 
730
+ /**
731
+ * What each adapter links, and what it links it against.
732
+ *
733
+ * Every entry in this table produces the same `handler.js` — the application
734
+ * as `Request` → `Response`, from `@uniflowed/server/fetch` — and differs only
735
+ * in the file wrapped around it and, for a target whose dependencies have a
736
+ * different build, in the export conditions that pick one. That is the whole
737
+ * of what an adapter is, and keeping the differences in one object is what
738
+ * stops a second one from quietly becoming a second application.
739
+ *
740
+ * `bun`, `deno` and `static` are deliberately absent; `uf_config`'s
741
+ * `DeployAdapter::is_implemented` is the other half of that fact and
742
+ * `docs/app/reference/cli/_uf.page.mdx` says why for each of them.
743
+ */
744
+ const ADAPTERS = {
745
+ node: {
746
+ entries: (document) => ({
747
+ handler: handlerEntrySource(document),
748
+ server: nodeEntrySource("./handler.js"),
749
+ }),
750
+ },
751
+ // The same two files. What `--adapter container` adds is a `Dockerfile` and
752
+ // a `.dockerignore`, and both are plain text that `uf` writes beside this
753
+ // output rather than anything the bundler produces — see `uf_cli`'s
754
+ // `commands::deploy`.
755
+ container: {
756
+ entries: (document) => ({
757
+ handler: handlerEntrySource(document),
758
+ server: nodeEntrySource("./handler.js"),
759
+ }),
760
+ },
761
+ edge: {
762
+ entries: (document) => ({
763
+ handler: handlerEntrySource(document),
764
+ worker: workerEntrySource("./handler.js"),
765
+ }),
766
+ // `workerd` first, so React resolves to the build that has
767
+ // `renderToReadableStream` and no `node:stream`. `browser` and `module`
768
+ // after it are Vite's own SSR defaults, kept so a dependency with no
769
+ // worker condition still resolves the way it does for every other target.
770
+ conditions: ["workerd", "worker", "edge-light", "browser", "module", "import", "default"],
771
+ },
772
+ serverless: {
773
+ entries: (document) => ({
774
+ handler: handlerEntrySource(document),
775
+ lambda: lambdaEntrySource("./handler.js"),
776
+ }),
777
+ },
778
+ };
779
+
710
780
  /**
711
781
  * Link the application into a directory that can be copied, for
712
782
  * `uf build --adapter`.
713
783
  *
714
784
  * `uf start` serves a build and `uf build --compile` puts one inside an
715
785
  * executable, and between them is the shape most hosts actually want: a
716
- * directory you copy onto a machine that has a JavaScript runtime and nothing
717
- * else — no `node_modules`, no checkout, no `uf`. That is what this writes.
786
+ * directory that carries everything and nothing that is still in the checkout
787
+ * — no `node_modules`, no source, no `uf`. That is what this writes, for
788
+ * whichever of [`ADAPTERS`] was asked for.
718
789
  *
719
790
  * It differs from the server build in [`build`] in one way, and that one way
720
791
  * is the whole of the difference between a build artefact and a checkout:
@@ -730,16 +801,17 @@ async function compile() {
730
801
  *
731
802
  * `handler.js` is the application as a Web-standard `fetch` export: a
732
803
  * `Request` in, a `Response` out, no filesystem, no socket, no `node:` import
733
- * that a worker does not already have. That is the seam every other target
734
- * in `app.runtime.deploy.adapters` is this file with a different thing wrapped
735
- * around it.
804
+ * that a worker does not already have. That is the seam, and it is the same
805
+ * file for every target in [`ADAPTERS`].
736
806
  *
737
- * `server.js` is the wrapper for *this* target: `node:http`, with the build's
738
- * files served from `static/` beside it. It is thirty lines, and that is the
739
- * point the work is in the handler, and what a second adapter has to write
740
- * is the thirty lines, not the application.
807
+ * The second entry is the wrapper for *this* target `node:http` for `node`
808
+ * and `container`, `export default { fetch }` for a Worker, `export const
809
+ * handler` for a Lambda and each of them is a handful of lines around an
810
+ * import from `@uniflowed/server`. That is the point: the work is in the
811
+ * handler, and what a new adapter has to write is the handful of lines, not
812
+ * the application.
741
813
  *
742
- * Both are ordinary entries of one Rolldown build, so `server.js` imports the
814
+ * Both are ordinary entries of one Rolldown build, so the wrapper imports the
743
815
  * emitted `handler.js` rather than a second copy of the application.
744
816
  *
745
817
  * The `static/` directory is *not* written here. `uf` copies it (see
@@ -765,11 +837,12 @@ async function deploy() {
765
837
  // future `uf` that knows an adapter this copy does not, and answering "one
766
838
  // moment, here is a directory" for a target nobody wrote would be the silent
767
839
  // wrong answer the whole issue is about.
768
- if (adapter !== "node") {
840
+ const shape = ADAPTERS[adapter];
841
+ if (shape == null) {
769
842
  throw new Error(
770
- `uf: this driver implements the \`node\` adapter and was asked for ${JSON.stringify(
771
- adapter,
772
- )}`,
843
+ `uf: this driver implements ${Object.keys(ADAPTERS)
844
+ .map((name) => JSON.stringify(name))
845
+ .join(", ")} and was asked for ${JSON.stringify(adapter)}`,
773
846
  );
774
847
  }
775
848
  const work = path.resolve(root, workArgument);
@@ -783,14 +856,29 @@ async function deploy() {
783
856
  // misbehaves.
784
857
  mkdirSync(work, { recursive: true });
785
858
  const document = assetsFromManifest(readManifest(outDir));
786
- writeFileSync(path.join(work, "handler.js"), handlerEntrySource(document));
787
- writeFileSync(path.join(work, "server.js"), nodeEntrySource("./handler.js"));
859
+ const entries = shape.entries(document);
860
+ const input = {};
861
+ for (const name of Object.keys(entries)) {
862
+ writeFileSync(path.join(work, `${name}.js`), entries[name]);
863
+ input[name] = path.join(work, `${name}.js`);
864
+ }
865
+
866
+ const ssr = { ...(inline.ssr ?? {}), noExternal: true };
867
+ if (shape.conditions != null) {
868
+ // Which build of a dependency this target gets, and it is the difference
869
+ // between a worker that renders and one that fails to link. React ships
870
+ // `server.node.js` under the `node` condition and `server.edge.js` under
871
+ // `workerd`; the first one imports `node:stream`, and the router picks its
872
+ // renderer by asking whether `renderToPipeableStream` is there — so the
873
+ // condition list is what decides that, not a flag in the application.
874
+ ssr.resolve = { ...(inline.ssr?.resolve ?? {}), conditions: shape.conditions };
875
+ }
788
876
 
789
877
  await vite.build({
790
878
  ...inline,
791
879
  customLogger: eventLogger("warn"),
792
880
  plugins: [...inline.plugins, nativeAddonGuard()],
793
- ssr: { ...(inline.ssr ?? {}), noExternal: true },
881
+ ssr,
794
882
  build: {
795
883
  ...inline.build,
796
884
  manifest: false,
@@ -805,10 +893,7 @@ async function deploy() {
805
893
  // that happens.
806
894
  emptyOutDir: false,
807
895
  rollupOptions: {
808
- input: {
809
- handler: path.join(work, "handler.js"),
810
- server: path.join(work, "server.js"),
811
- },
896
+ input,
812
897
  output: {
813
898
  entryFileNames: "[name].js",
814
899
  // Route modules are lazy `import()`s, so the server bundle splits
@@ -893,11 +978,10 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
893
978
  // trap in it.
894
979
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
895
980
 
896
- // Not \`await serve(...)\` at the top level: Node runs top-level \`await\` happily
897
- // and the Flow parser uf vendors does not (ubugeeei-prod/uf#204), so the generated
898
- // entry would fail its own transform. \`.catch\` is the better spelling anyway
899
- // a server that cannot take its port should say so and exit non-zero, rather
900
- // than die as an unhandled rejection.
981
+ // Not \`await serve(...)\` at the top level. uf parses that now
982
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
983
+ // is the better spelling regardless a server that cannot take its port should
984
+ // say so and exit non-zero, rather than die as an unhandled rejection.
901
985
  serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
902
986
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
903
987
  process.exit(1);
@@ -905,6 +989,60 @@ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
905
989
  `;
906
990
  }
907
991
 
992
+ /**
993
+ * The source of `worker.js`: the Cloudflare Workers entry around that handler.
994
+ *
995
+ * `export default { fetch }`, which is the modules-format Worker Cloudflare
996
+ * runs, and everything host-specific is in `@uniflowed/server/edge` — the
997
+ * asset lookup through the `ASSETS` binding `wrangler.json` declares, and the
998
+ * `ctx.waitUntil` that keeps the isolate alive for `after()`.
999
+ *
1000
+ * `beginRequest` comes from the handler beside this file for the reason
1001
+ * `nodeEntrySource` gives: the request has to be established in the storage the
1002
+ * *application* reads. See ubugeeei-prod/uf#389.
1003
+ */
1004
+ function workerEntrySource(handlerSpecifier) {
1005
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1006
+ import { createWorkerFetch } from "@uniflowed/server/edge";
1007
+
1008
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1009
+
1010
+ export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1011
+ `;
1012
+ }
1013
+
1014
+ /**
1015
+ * The source of `lambda.js`: the AWS Lambda entry around that handler.
1016
+ *
1017
+ * `export const handler`, so the function's configured handler is
1018
+ * `lambda.handler`. Everything platform-specific — the payload format 2.0
1019
+ * event, the base64 rules, the `cookies` array — is in
1020
+ * `@uniflowed/server/lambda`.
1021
+ *
1022
+ * `staticDir` points at the `static/` copied beside this file, so an uploaded
1023
+ * package answers a prerendered document without any other infrastructure
1024
+ * existing. That is a starting point rather than a destination, and the module
1025
+ * it is passed to says so at length.
1026
+ */
1027
+ function lambdaEntrySource(handlerSpecifier) {
1028
+ return `// Generated by \`uf build --adapter serverless\`. Not checked in, not edited.
1029
+ import path from "node:path";
1030
+ import { fileURLToPath } from "node:url";
1031
+
1032
+ import { createLambdaHandler } from "@uniflowed/server/lambda";
1033
+
1034
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1035
+
1036
+ // Resolved from this file and not from the working directory: Lambda sets the
1037
+ // working directory to the task root today and is under no obligation to keep
1038
+ // doing so, and a deployment that only found its own assets by accident is a
1039
+ // deployment with a trap in it.
1040
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1041
+
1042
+ export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
1043
+ `;
1044
+ }
1045
+
908
1046
  /**
909
1047
  * The source of the module a runtime gets wrapped around.
910
1048
  *
@@ -914,11 +1052,11 @@ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
914
1052
  * and not inside the binary.
915
1053
  */
916
1054
  function entrySource(assetsSpecifier, document) {
917
- // Not `await serve(...)` at the top level. Node runs top-level `await`
918
- // happily and the Flow parser uf vendors does not parse it (ubugeeei-prod/uf#204),
919
- // so the generated entry would fail its own transform. `.catch` is the better
920
- // spelling anyway: a binary that cannot take its port should say which port
921
- // and exit non-zero, rather than die as an unhandled rejection.
1055
+ // Not `await serve(...)` at the top level. uf parses that now
1056
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work;
1057
+ // `.catch` is the better spelling regardless: a binary that cannot take its
1058
+ // port should say which port and exit non-zero, rather than die as an
1059
+ // unhandled rejection.
922
1060
  return `// Generated by \`uf build --compile\`. Not checked in, not edited.
923
1061
  import { serve } from "@uniflowed/server/standalone";
924
1062
  import { assets } from ${JSON.stringify(assetsSpecifier)};
package/index.js CHANGED
@@ -24,6 +24,13 @@
24
24
  // markdown, front matter, heading ids and build-time syntax
25
25
  // highlighting, so `.mdx` works with
26
26
  // no configuration.
27
+ // * `uf:asset` — an imported image is decoded, resized to the widths the
28
+ // project declares and re-encoded by `uf assets`, and an
29
+ // imported font is self-hosted with the `@font-face` and the
30
+ // metric-matched fallback that stop the swap moving the page.
31
+ // The import evaluates to what `Image` and `Font` need — the
32
+ // intrinsic size, every emitted variant, the placeholder —
33
+ // rather than to a URL string. See `internal/assets.js`.
27
34
  //
28
35
  // `uniflowed(options)` returns the array; a project that wants to add a plugin
29
36
  // declares it in `uf.config.js` and the driver appends it after these.
@@ -34,6 +41,7 @@ import path from "node:path";
34
41
  import mdx from "@mdx-js/rollup";
35
42
  import rehypeSlug from "rehype-slug";
36
43
 
44
+ import { assetPlugin } from "./internal/assets.js";
37
45
  import { emit, reportRenderError } from "./internal/events.js";
38
46
  import { highlightPlugin } from "./internal/highlight.js";
39
47
  import remarkFrontmatter from "remark-frontmatter";
@@ -99,8 +107,17 @@ export default function uniflowed(options = {}) {
99
107
  const routerRoot = app.router?.root ?? "app";
100
108
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
101
109
  const markdown = app.builtins?.markdown ?? {};
102
-
103
- return [flowPlugin({ routerRoot, appEntry, command: options.command }), mdxPlugin(markdown)];
110
+ const builtins = app.builtins ?? {};
111
+
112
+ return [
113
+ flowPlugin({ routerRoot, appEntry, command: options.command }),
114
+ mdxPlugin(markdown),
115
+ assetPlugin({
116
+ images: builtins.images ?? {},
117
+ fonts: builtins.fonts ?? {},
118
+ command: options.command,
119
+ }),
120
+ ];
104
121
  }
105
122
 
106
123
  function flowPlugin({ routerRoot, appEntry, command }) {
@@ -387,21 +404,40 @@ function flowPlugin({ routerRoot, appEntry, command }) {
387
404
  // `after()` runs than the same project run through `uf dev`; see
388
405
  // `internal/serve.js` and ubugeeei-prod/uf#389.
389
406
  //
390
- // Only document requests reach here, so unlike `driver.js` there is
391
- // no path where uf hands the response back to Vite's chain: what is
392
- // below either writes it or throws.
407
+ // Only requests that look like a document reach here, so unlike
408
+ // `driver.js` there is no path where uf hands the response back to
409
+ // Vite's chain: what is below either writes it or throws.
393
410
  await withRequest(entry, asRequest, async () => {
394
- // Before the page: a middleware guards a subtree, and a page
395
- // rendered while the guard on it had not run is the whole of
396
- // ubugeeei-prod/uf#260. Only document requests reach here, so this
397
- // is the page half of the guarantee; `driver.js` makes the same
398
- // call above the route handlers, for every method.
411
+ // Before anything answers: a middleware guards a subtree, and a
412
+ // page rendered while the guard on it had not run is the whole of
413
+ // ubugeeei-prod/uf#260. `driver.js` makes the same call, for
414
+ // every method.
399
415
  const guarded = await entry.runMiddleware(asRequest);
400
416
  if (guarded != null) {
401
417
  await send(response, guarded);
402
418
  return;
403
419
  }
404
420
 
421
+ // Then the route handlers, above the renderer and for the same
422
+ // reason `driver.js` puts them there: a path that answers a
423
+ // request is not a document, whatever the client said it would
424
+ // accept. `curl /api/thing` and a `<form action>` navigation both
425
+ // send `Accept: text/html`, and both want the handler's answer.
426
+ //
427
+ // This step is not a duplicate of the dispatcher in `driver.js`,
428
+ // it is the only one that can run: this middleware is mounted by
429
+ // `configureServer`, which Vite calls while it is building the
430
+ // server, and `uf dev` adds its own after `createServer` has
431
+ // returned — so for every request this one claims, it is the one
432
+ // that decides. Without it a route handler under `uf dev` was
433
+ // reachable only by a client that asked for something other than
434
+ // HTML, and answered the 404 page to everyone else.
435
+ const handled = await entry.dispatch(asRequest);
436
+ if (handled != null) {
437
+ await send(response, handled);
438
+ return;
439
+ }
440
+
405
441
  const result = await entry.render(
406
442
  url,
407
443
  { scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
@@ -0,0 +1,396 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: Vite imports this module directly, before any transform.
4
+ //
5
+ // `uf:asset` — what an imported image or font becomes.
6
+ //
7
+ // The name and the hook set are not new. `crates/uf_plugin/src/builtin.rs` has
8
+ // declared `uf:asset` — "resolves, fingerprints, and emits non-JavaScript
9
+ // imports", `resolveId` + `load` + `generateBundle` + `writeBundle` +
10
+ // `transformIndexHtml` — since before there was anything behind it, and
11
+ // `uf inspect` has been listing it in the resolved pipeline. This is the
12
+ // implementation of a plugin uf was already claiming to run.
13
+ //
14
+ // # What an import becomes
15
+ //
16
+ // ```js
17
+ // import hero from "./hero.jpg";
18
+ // <Image src={hero} alt="…" sizes="(max-width: 640px) 100vw, 640px" />
19
+ // ```
20
+ //
21
+ // `hero` is not a URL string. It is the manifest `crates/uf_assets` produced —
22
+ // the intrinsic width and height, every emitted variant with its own width, the
23
+ // blur placeholder — because a `srcSet` can only be written by something that
24
+ // knows which other sizes exist, and a URL string does not.
25
+ //
26
+ // A font import is the same shape: the self-hosted file, the `@font-face` rules
27
+ // that declare it, and the metric-matched fallback.
28
+ //
29
+ // # Where the work happens, and when
30
+ //
31
+ // In `uf`, over the `uf assets` protocol — one native process for the whole
32
+ // build rather than an image codec in the dependency tree. Both schedules go
33
+ // through the same process with the same parameters, and both write to the
34
+ // same cache directory:
35
+ //
36
+ // * **`uf build`** reads each emitted variant out of the cache and hands it to
37
+ // Rollup with `emitFile`, so the bundler owns what lands in `dist/` and the
38
+ // size report counts it.
39
+ // * **`uf dev`** serves the same files out of the same cache directory over a
40
+ // middleware, transformed on the first import and reused after.
41
+ //
42
+ // The files are named by a content hash of the source and the parameters, so
43
+ // the second build of an unchanged image does no work in either mode and a dev
44
+ // session warms the cache a build then reuses. That is also the whole of "the
45
+ // two must agree": there is one pipeline and one set of bytes, and the only
46
+ // thing that differs between them is the URL prefix they are served under.
47
+ //
48
+ // # What this plugin deliberately does not claim
49
+ //
50
+ // An import with a query — `./hero.png?url`, `?raw`, `?inline` — is left to
51
+ // Vite. Those are Vite's own asset conventions and a project reaching for one
52
+ // is reaching past uf on purpose; claiming them here would make a documented
53
+ // Vite feature unreachable from a uf project, which is red line 8 in
54
+ // `docs/red-lines.md`. `import hero from "./hero.png"` is uf's; everything
55
+ // with a `?` after it is Vite's.
56
+
57
+ import { existsSync, readFileSync } from "node:fs";
58
+ import path from "node:path";
59
+
60
+ import { AssetService, assetKind } from "@uniflowed/host/assets";
61
+
62
+ /** Where transformed assets are kept, relative to the project root. */
63
+ export const CACHE_DIR = ".uf/cache/assets";
64
+
65
+ /**
66
+ * The URL prefix a dev server answers transformed assets on.
67
+ *
68
+ * `@` first, following the convention Vite uses for everything that is not a
69
+ * file in the project: it cannot collide with a real path, and Vite's own
70
+ * middlewares leave it alone.
71
+ */
72
+ export const DEV_PREFIX = "@uf-asset/";
73
+
74
+ /**
75
+ * The module source for one transformed asset.
76
+ *
77
+ * A frozen object literal rather than a JSON blob assigned to a variable: this
78
+ * is what the component destructures, it is small, and a build that inlines it
79
+ * into the one component that used it is the right outcome.
80
+ */
81
+ export function assetModuleSource(manifest) {
82
+ return `export default Object.freeze(${JSON.stringify(manifest)});\n`;
83
+ }
84
+
85
+ /**
86
+ * The `srcSet` for one format, and the URLs that go in it.
87
+ *
88
+ * Written here rather than in the component so that a build and a dev server
89
+ * cannot produce different strings from the same manifest: the only input that
90
+ * differs between them is `baseUrl`, and it is an argument.
91
+ */
92
+ export function withUrls(image, baseUrl) {
93
+ const variants = image.variants.map((variant) => ({
94
+ ...variant,
95
+ url: `${baseUrl}${variant.file}`,
96
+ }));
97
+ // Widest last within a format, which is the order a `srcset` reads best in
98
+ // and the order `sizes` is evaluated against.
99
+ variants.sort((left, right) => left.width - right.width);
100
+
101
+ const formats = [];
102
+ for (const variant of variants) {
103
+ if (variant.format === image.format) continue;
104
+ if (!formats.includes(variant.format)) formats.push(variant.format);
105
+ }
106
+
107
+ const srcSetFor = (format) =>
108
+ variants
109
+ .filter((variant) => variant.format === format)
110
+ // `640w` and not `2x`: a density descriptor describes one layout width,
111
+ // and the whole point of the ladder is that the layout width is not
112
+ // known here. With `w`, the browser combines it with `sizes` and picks.
113
+ .map((variant) => `${variant.url} ${variant.width}w`)
114
+ .join(", ");
115
+
116
+ const fallbacks = variants.filter((variant) => variant.format === image.format);
117
+ const widest = fallbacks[fallbacks.length - 1] ?? variants[variants.length - 1];
118
+
119
+ return {
120
+ src: widest?.url ?? null,
121
+ width: image.width,
122
+ height: image.height,
123
+ srcSet: srcSetFor(image.format),
124
+ // Alternatives first: a browser takes the first `<source>` it understands,
125
+ // so the format every browser understands must not be offered before the
126
+ // ones that are smaller.
127
+ sources: formats.map((format) => ({
128
+ type: variants.find((variant) => variant.format === format).mime,
129
+ srcSet: srcSetFor(format),
130
+ })),
131
+ blurDataURL: image.blur,
132
+ // Carried through so a project can see what the pipeline decided and why,
133
+ // rather than having to infer it from what is missing: `hero.declined` is
134
+ // the widths where the alternative format was encoded and came out larger,
135
+ // with both byte counts. Nothing prints them — a line on every build about
136
+ // a format that was correctly not emitted is noise — and `uf explain build`
137
+ // is where the limit itself is stated.
138
+ declined: image.declined,
139
+ note: image.note,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * uf's asset pipeline, as a Vite plugin.
145
+ *
146
+ * @param {object} options
147
+ * @param {object} [options.images] `app.builtins.images`
148
+ * @param {object} [options.fonts] `app.builtins.fonts`
149
+ * @param {string} [options.command] the `uf` binary to transform through
150
+ */
151
+ export function assetPlugin({ images = {}, fonts = {}, command } = {}) {
152
+ // Both halves can be turned off independently, and a plugin that is off is
153
+ // still in the array: `uf inspect` lists the resolved pipeline, and a
154
+ // pipeline that changes shape when a feature is disabled is a pipeline whose
155
+ // listing cannot be compared between two projects.
156
+ const imagesOn = images.enabled !== false;
157
+ const fontsOn = fonts.enabled !== false;
158
+
159
+ let root = process.cwd();
160
+ let base = "/";
161
+ let assetsDir = "assets";
162
+ let isBuild = false;
163
+ /** @type {import("vite").ViteDevServer | null} */
164
+ let server = null;
165
+ /** @type {AssetService | null} */
166
+ let service = null;
167
+ /**
168
+ * The manifest for each source path, so one image is transformed once.
169
+ *
170
+ * `uf build` runs Vite twice over the same modules — once for the browser
171
+ * bundle and once for the server one — from a single plugin array, so both
172
+ * passes share this map and the second decodes nothing. It survives
173
+ * `buildEnd` deliberately: clearing it there is what made the server pass
174
+ * redo every image, which is the whole cost this map exists to avoid.
175
+ */
176
+ const transformed = new Map();
177
+
178
+ const cacheDir = () => path.resolve(root, CACHE_DIR);
179
+ /**
180
+ * The `uf assets` process, started on the first asset and not before.
181
+ *
182
+ * Lazily rather than in `buildStart`, which is where `uf:flow` starts its
183
+ * transform service: that one is going to be asked about every module in the
184
+ * project, and this one is asked about nothing at all in a project that
185
+ * imports no images or fonts. A build that has no use for an image codec
186
+ * should not spawn one.
187
+ */
188
+ const ensureService = () => {
189
+ service ??= new AssetService({ command, root });
190
+ return service;
191
+ };
192
+
193
+ /**
194
+ * Where a transformed file is served from.
195
+ *
196
+ * A build's URL is the bundler's output directory; a dev server's is this
197
+ * plugin's own middleware. This is the *only* thing that differs between the
198
+ * two schedules, and it is one string.
199
+ */
200
+ const baseUrl = () => (isBuild ? `${base}${assetsDir}/` : `${base}${DEV_PREFIX}`);
201
+
202
+ const claims = (id) => {
203
+ // A query is Vite's, not uf's. See the header.
204
+ if (id.includes("?")) return null;
205
+ if (id.startsWith("\0")) return null;
206
+ const kind = assetKind(id);
207
+ if (kind === "image" && !imagesOn) return null;
208
+ if (kind === "font" && !fontsOn) return null;
209
+ return kind;
210
+ };
211
+
212
+ return {
213
+ name: "uf:asset",
214
+ // Before Vite's own asset handling, which would otherwise claim the same
215
+ // extensions and return a URL string.
216
+ enforce: "pre",
217
+
218
+ configResolved(config) {
219
+ root = config.root;
220
+ base = config.base;
221
+ assetsDir = config.build?.assetsDir ?? "assets";
222
+ isBuild = config.command === "build";
223
+ },
224
+
225
+ async load(id) {
226
+ const kind = claims(id);
227
+ if (kind == null) return null;
228
+ const file = path.resolve(id);
229
+ // Not this plugin's to fail on: an id with one of these extensions that
230
+ // is not a file on disk is a virtual module somebody else owns.
231
+ if (!existsSync(file)) return null;
232
+
233
+ return loadAsset.call(this, {
234
+ kind,
235
+ file,
236
+ transformed,
237
+ service: ensureService(),
238
+ cacheDir: cacheDir(),
239
+ baseUrl: baseUrl(),
240
+ assetsDir,
241
+ isBuild,
242
+ images,
243
+ fonts,
244
+ });
245
+ },
246
+
247
+ configureServer(devServer) {
248
+ server = devServer;
249
+ devServer.httpServer?.once("close", () => {
250
+ service?.close();
251
+ service = null;
252
+ });
253
+
254
+ // Before Vite's own middlewares: nothing else knows this prefix, and the
255
+ // files are outside the module graph, so there is nothing to wait for.
256
+ const directory = cacheDir();
257
+ devServer.middlewares.use((request, response, next) => {
258
+ const url = request.url ?? "";
259
+ const at = url.indexOf(DEV_PREFIX);
260
+ if (at === -1) return next();
261
+ const name = decodeURIComponent(url.slice(at + DEV_PREFIX.length).split("?")[0]);
262
+ // The name is a file name and nothing else. Every emitted name is one
263
+ // path segment by construction, so a request carrying a separator is
264
+ // not a name this plugin ever minted — refusing it rather than
265
+ // resolving it is what keeps the cache directory from being a way to
266
+ // read the rest of the disk.
267
+ if (name === "" || name.includes("/") || name.includes("\\") || name.includes("..")) {
268
+ response.statusCode = 400;
269
+ response.end("bad asset name");
270
+ return;
271
+ }
272
+ const target = path.join(directory, name);
273
+ if (!existsSync(target)) return next();
274
+ response.setHeader("Content-Type", contentTypeOf(name));
275
+ // The name is a content hash, so the bytes under it never change.
276
+ response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
277
+ response.end(readFileSync(target));
278
+ });
279
+ },
280
+
281
+ watchChange(id) {
282
+ // The memo below is what stops `uf build` decoding every image twice,
283
+ // once per bundle. In a dev server it would also stop uf ever noticing
284
+ // that an image was edited: Vite invalidates the module and calls `load`
285
+ // again, and `load` would hand back the manifest it made before the
286
+ // change. Dropping both keys is cheap and the next `load` redoes the
287
+ // work — which, because the emitted names are content hashes, writes new
288
+ // files and leaves the old ones for anything still holding a URL.
289
+ transformed.delete(`image:${path.resolve(id)}`);
290
+ transformed.delete(`font:${path.resolve(id)}`);
291
+ },
292
+
293
+ buildEnd() {
294
+ // A dev server keeps its service for the whole session; a build is done
295
+ // with it here. The same rule `uf:flow` follows next door.
296
+ //
297
+ // `transformed` is *not* cleared. A build's second pass over the same
298
+ // modules then needs no process at all — every answer is already in the
299
+ // map, and `ensureService` is never reached.
300
+ if (server == null) {
301
+ service?.close();
302
+ service = null;
303
+ }
304
+ },
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Transform one asset and return the module that stands for it.
310
+ *
311
+ * Split out of the hook so the hook stays readable and so the memoisation is
312
+ * visible: `uf build` runs Vite twice over the same modules, once for the
313
+ * browser bundle and once for the server one, and an image transformed on both
314
+ * passes would be decoded twice for one build.
315
+ */
316
+ async function loadAsset(context) {
317
+ const { kind, file, transformed, service, cacheDir, baseUrl, assetsDir, isBuild, images, fonts } =
318
+ context;
319
+ const key = `${kind}:${file}`;
320
+ let manifest = transformed.get(key);
321
+ if (manifest == null) {
322
+ manifest =
323
+ kind === "image"
324
+ ? await service.image(file, {
325
+ outDir: cacheDir,
326
+ widths: images.widths,
327
+ quality: images.quality,
328
+ blur: images.placeholder,
329
+ })
330
+ : await service.font(file, {
331
+ outDir: cacheDir,
332
+ family: fonts.family,
333
+ display: fonts.display,
334
+ baseUrl,
335
+ });
336
+ transformed.set(key, manifest);
337
+ }
338
+
339
+ const files = kind === "image" ? manifest.variants.map((v) => v.file) : [manifest.file];
340
+ if (isBuild) {
341
+ // Handed to Rollup rather than copied by hand, so the bundler owns what
342
+ // lands in the output directory and `uf_bundle`'s size report — which
343
+ // walks that directory — counts every one of them.
344
+ for (const name of files) {
345
+ this.emitFile({
346
+ type: "asset",
347
+ // `fileName` rather than `name`: the name is already a content hash of
348
+ // the source and the parameters, and letting Rollup hash it again
349
+ // would move it on every encoder change while saying nothing new.
350
+ fileName: `${assetsDir}/${name}`,
351
+ source: readFileSync(path.join(cacheDir, name)),
352
+ });
353
+ }
354
+ }
355
+
356
+ if (kind === "image") {
357
+ return assetModuleSource(withUrls(manifest, baseUrl));
358
+ }
359
+ return assetModuleSource({
360
+ src: `${baseUrl}${manifest.file}`,
361
+ family: manifest.family,
362
+ fallbackFamily: manifest.fallbackFamily,
363
+ // The stack a page should set `font-family` to: the real face, then the
364
+ // metric-matched fallback, then the local face it was scaled from. Written
365
+ // here so no page has to remember that the fallback only ever applies when
366
+ // it is named after the real face.
367
+ fontFamily: [manifest.family, manifest.fallbackFamily, manifest.fallback?.local]
368
+ .filter((name) => name != null)
369
+ .map((name) => JSON.stringify(name))
370
+ .join(", "),
371
+ type: manifest.mime,
372
+ css: manifest.css,
373
+ metrics: manifest.metrics,
374
+ fallback: manifest.fallback,
375
+ fallbackDeclined: manifest.fallbackDeclined,
376
+ });
377
+ }
378
+
379
+ /** The media type for one emitted file name. */
380
+ function contentTypeOf(name) {
381
+ const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
382
+ const types = {
383
+ avif: "image/avif",
384
+ gif: "image/gif",
385
+ jpg: "image/jpeg",
386
+ jpeg: "image/jpeg",
387
+ otf: "font/otf",
388
+ png: "image/png",
389
+ svg: "image/svg+xml",
390
+ ttf: "font/ttf",
391
+ webp: "image/webp",
392
+ woff: "font/woff",
393
+ woff2: "font/woff2",
394
+ };
395
+ return types[extension] ?? "application/octet-stream";
396
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.8",
3
+ "version": "0.0.0-alpha.9",
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,8 +25,8 @@
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.8",
29
- "@uniflowed/server": "0.0.0-alpha.8",
28
+ "@uniflowed/host": "0.0.0-alpha.9",
29
+ "@uniflowed/server": "0.0.0-alpha.9",
30
30
  "rehype-slug": "^6.0.0",
31
31
  "remark-frontmatter": "^5.0.0",
32
32
  "remark-gfm": "^4.0.1",