Readiness: Implemented
Build an app
Images, fonts, icons and cards
Import an image and it is resized to the widths your layout asks for. Import a
font and it is self-hosted, declared, and paired with a fallback that occupies
exactly the same space. Import an icon and it joins one sprite built from the
set your build actually reached. Import a card template and it is drawn. All of
it happens in uf itself, at build time, and this page is as clear about what
the pipeline cannot do as about what it can.
What you will be able to do: import images, fonts, icons and Open Graph cards and get them resized, self-hosted, collected into a sprite or drawn at build time; serve remote images resized on request from the hosts you list; and know where that pipeline stops.
What you need first: a project with routes to put them on — Routing.
An image
Import it, and hand the whole import to Image:
import * as React from "@uniflowed/react";
import { Image } from "@uniflowed/web";
import hero from "./hero.jpg";
export component Page() {
return <Image src={hero} alt="The bridge at dusk" sizes="(max-width: 640px) 100vw, 640px" />;
}
hero is not a URL. It is what uf assets produced when it decoded the file:
the intrinsic width and height, every variant it wrote, and a blur placeholder.
Image turns that into markup — a srcset naming each width, the intrinsic
dimensions that reserve the box before the bytes arrive, loading="lazy" and
decoding="async".
That is the difference from writing <img> yourself, and it is not a small one:
a srcset can only be written by something that knows what other sizes exist,
and until you have made them, nothing does.
What ends up in dist/
One variant per declared width that is not wider than the source — uf never upscales — plus one at the source's own width, so there is always a largest. Each is content-hashed, so its name changes exactly when its bytes do and never otherwise: a rebuild that changed nothing invalidates nothing.
The blur placeholder
A tiny version of the image is inlined into the document as a background-image
and the real one paints over it. It costs one decode uf was doing anyway, and no
element — so there is nothing to remove when the image lands, and nothing extra
for a screen reader to announce.
An image with transparency gets none, and that is deliberate: the placeholder is
painted under the image, so under a logo with a transparent background it
would stay visible through it forever. Set placeholder={false} to turn it off
for an opaque image too.
The one above the fold
<Image src={hero} alt="…" priority={true} />
priority makes the image eager, gives it fetchpriority="high", and emits a
<link rel="preload"> asking for the same variant the <img> will. A srcset
behind a lazy <img> is discovered late, and for the largest image on the page
that is most of the Largest Contentful Paint. Everything else stays lazy, which
is the right default for the images nobody has scrolled to yet.
sizes
Set it. A srcset with w descriptors and no sizes means 100vw to the
browser, so it fetches a variant wide enough for the whole viewport however
small the image is drawn. When you say nothing, uf writes
(max-width: <intrinsic>px) 100vw, <intrinsic>px, which at least never asks for
more pixels than exist — but it is a guess about a layout uf cannot see, and
yours is better.
A remote image
An image whose URL is only known when the page renders — a CMS asset, a user
upload — was never imported, so uf assets never saw it. For those there is an
endpoint, and it is off until you list the hosts it may fetch from:
export default defineConfig({
app: {
builtins: {
images: {
remotePatterns: [
{ hostname: "images.example.com", pathname: "/uploads/**" },
{ hostname: "*.cdn.example.com" },
],
},
},
},
});
Then pass the URL to Image as a string, with its dimensions:
<Image src={post.cover.url} alt={post.cover.alt} width={1600} height={900} />
Image sends it through /__uf/image and writes the srcset for you — one
rung per width in app.builtins.images.widths that is narrower than the image,
and one that covers it, described at the width it will actually come back at:
<img src="/__uf/image?url=https%3A%2F%2Fimages.example.com%2Fuploads%2Fa.jpg&w=1920&q=75"
srcset="/__uf/image?url=…&w=640&q=75 640w, … , /__uf/image?url=…&w=1920&q=75 1600w" …>
width and height are still required: the image is fetched only after the
page has been laid out, so nothing else can reserve its box. Pass
quality={60} for another quality — one the project allows, see below — and
unoptimized to leave one image's URL exactly as written. A srcSet of your
own is left alone too.
What the endpoint does
One request is one width of one image: url, w and q.
- It fetches the source, resizes it to
w— never wider than the source — and re-encodes it atq: as AVIF when the browser'sAcceptnamesimage/avif, and in the source's own family otherwise (a JPEG stays a JPEG, a PNG a PNG, a WebP becomes a PNG). EXIF orientation is applied and the rest of the metadata, GPS position included, is dropped. - The answer is cached, keyed on the URL, the width, the quality and the
format that was negotiated, for as long as the origin's
Cache-Controlmax-agesays — held between a minute and a year, an hour when it says nothing — and not at all when it saysno-storeorprivate. The response says which it was inx-uf-cache, and carriesVary: Accept. - The cache is the route cache's store: in memory by default, and under the
provider
rendering.cache.storenames when it names one, so a durable store keeps variants across restarts and between processes too.
What it refuses
It is a server fetching a URL a stranger chose, so it refuses a great deal, and says why in a plain-text response:
| Request | Answer |
|---|---|
| A host, port, protocol or path no pattern admits, or a URL with credentials in it | 403 |
| A redirect to anywhere no pattern admits — every hop is checked, three at most | 403 |
| A host that resolves to a loopback, private or link-local address | 403 |
A w not in widths, a q not in quality or qualities, a relative url | 400 |
| A source larger than 32 MiB, counted as it arrives | 413 |
| Anything but a PNG, a JPEG or a WebP, judged by its bytes — an SVG above all | 415 |
| More than four encodes running and sixty-four waiting | 503 |
The address check is made after the name is resolved, against every
address it resolves to, and the connection goes to the address that was
checked rather than to the name a second time — so a hostname you listed
cannot be pointed at 169.254.169.254 by whoever controls its DNS.
http has to be said in a pattern; a pattern without protocol admits HTTPS
only. A pattern without port admits the protocol's default port and no other.
*.example.com is exactly one more label and **.example.com any number;
neither matches example.com itself. A bare * is refused when the config is
read: an allow-list that admits every host is not an allow-list.
Where it runs, and what encodes
| Door | Encoder |
|---|---|
uf dev, uf preview, uf start | uf itself, pure Rust: rav1e for AVIF, the same PNG and JPEG encoders as the build |
uf build --adapter edge | Cloudflare's Images binding, declared in the wrangler.json uf writes |
--adapter node, bun, deno, container, serverless | the module app.builtins.images.transformer names |
uf build --compile | none: refused while remotePatterns lists a host |
A deployed directory has no uf in it, so it cannot encode the way uf start
does. A Worker has an image service of its own, and uf hands it the bytes it
already fetched and checked — not the URL, because a service that fetched the
URL itself would follow redirects uf never saw. A process target has no image
service, so it gets the encoder you name:
// image-transformer.js
import sharp from "sharp";
export function createImageTransformer() {
return async ({ bytes, width, quality, avif, type }) => {
const resized = sharp(bytes).rotate().resize({ width, withoutEnlargement: true });
const encoded = avif
? await resized.avif({ quality }).toBuffer()
: type === "image/jpeg"
? await resized.jpeg({ quality }).toBuffer()
: await resized.png().toBuffer();
return {
bytes: new Uint8Array(encoded),
type: avif ? "image/avif" : type === "image/jpeg" ? "image/jpeg" : "image/png",
};
};
}
images: { remotePatterns: [/* … */], transformer: "./image-transformer.js" },
Without one, uf build --adapter node with remote patterns fails, naming
the key, rather than writing a server whose endpoint fetches every image and
then cannot encode it. The allow-list, the redirect check, the byte bound, the
format check and the cache are uf's on every target; only the encode is
delegated. On a Worker the address check is the one that does not exist — a
Worker has no resolver, and it fetches from Cloudflare's network rather than
from the one your databases sit in.
uf build --compile has no endpoint at all — the standalone server is its own
front door and carries no encoder — so it refuses a project with remote
patterns rather than writing a binary whose remote images are all 404s.
Testing against your own machine
A test that serves its own images from 127.0.0.1 needs the private-address
refusal turned off:
images: {
remotePatterns: [{ protocol: "http", hostname: "127.0.0.1", port: "4100" }],
dangerouslyAllowPrivateAddresses: true,
},
The name is the documentation. Never set it on anything another person can reach: it is what stops the endpoint being a way into the network the server runs in.
A font
import { Font } from "@uniflowed/web";
import inter from "./Inter.woff2";
export component Layout(children: React.Node) {
return (
<div style={{ fontFamily: inter.fontFamily }}>
<Font src={inter} />
{children}
</div>
);
}
Three things happen. The file is self-hosted — copied into your build under
a content hash, so no request goes to a third party at runtime. It is
declared, with font-display: swap and the right format(). And it is
paired with a metric-matched fallback: a second @font-face over a font the
reader already has, carrying size-adjust, ascent-override,
descent-override and line-gap-override computed from your font's own head,
hhea and OS/2 tables.
That last one is the reason to bother. font-display: swap shows text
immediately in a fallback and then swaps, and the swap moves every line, because
the two faces are different sizes. Scale the fallback until it occupies the same
space and the swap moves nothing.
Use inter.fontFamily rather than writing the family name yourself: it is the
real face, then the matched fallback, then the local face that was scaled. Name
only the real family and you get the download and none of the metric matching.
The unicode-range split
A family that covers Latin, Greek and Cyrillic is three scripts in one file, and a reader of one of them downloads all three. Turn the split on and they do not:
export default defineConfig({
app: { builtins: { fonts: { subset: "ranges" } } },
});
uf reads the font's own character map, partitions it into script buckets, and
emits one file per bucket with the exact unicode-range of what is in it —
computed from the subset, not copied from a published table, so the browser
never fetches a bucket for a character it does not have and never misses one.
The buckets are disjoint, so nothing is in two files.
Nothing is lost. That is the reason this is the mode to turn on and
subset: "none" is the default: a page that renders Cyrillic still gets
Cyrillic, it just does not pay for it before it needs it. The subsetting itself
is skera, Google Fonts' Rust port
of HarfBuzz's hb-subset, so the glyph closure, the GSUB/GPOS pruning and
the cmap rebuild are the same ones the rest of the web's fonts go through.
There is a second, lossy mode — cutting a face down to a named string —
and it is reachable from the uf assets protocol and not yet from a .js
file. The characters a face is cut to are a property of one use of it rather
than of the project, so it wants to be said at the import, and an import can
only carry a query — which is Vite's. Until that is resolved, text
subsetting is something a tool driving uf assets can ask for and an
application cannot.
Why the default is none. Subsetting is lossy by construction: a glyph that
was removed is a character the page can no longer draw. A build cannot see the
text a server will render, a user will type, or a translation will introduce, so
a toolchain that quietly cut fonts down to the strings in your source would
produce pages that look right to you and render in tofu for somebody with a
diacritic in their name. "ranges" is safe because it removes nothing.
The preload
Font emits exactly one <link rel="preload">, whatever the family was split
into. That is deliberate: a preload for every bucket downloads the whole family
up front, which is what the split existed to stop, and four preloaded faces have
pushed your own stylesheet down the same connection.
<Font src={inter} preload={false} />
for a face that does not paint the first screen. app.builtins.fonts.preload
sets the default for every import, and preload={true} overrides it the other
way for a page that does need the face early. One link is the most Font ever
emits.
An icon
import { Icon, IconSprite } from "@uniflowed/web";
import sprite from "uf:icon-sprite";
import star from "uf:icon/star";
export component Layout(children: React.Node) {
return (
<>
<IconSprite sprite={sprite} />
<button type="button"><Icon icon={star} label="Favourite" /></button>
{children}
</>
);
}
uf:icon/star resolves to icons/star.svg — the directory is
app.builtins.icons.dir — and evaluates to the symbol's id and viewBox, not
to markup. uf:icon-sprite is one <svg> holding a <symbol> for every icon
the build reached, and no others.
The sprite is inlined into the document rather than fetched. An external
sprite would be the better answer if it worked — one file cached across every
page — but <use href="sprite.svg#id"> has never resolved across documents in
Safari and is blocked cross-origin in Chrome, so a sprite file in dist/ would
be weight nothing could use.
That last part — "and no others" — is what a runtime icon library cannot do. At runtime nothing
knows which of a set's nine hundred icons an application imported, so the
library ships all of them or asks you to import each one as its own component
and repeat the same <svg> wrapper nine hundred times. A build resolved every
import; the set it reached is the set.
label is the whole accessibility decision an icon needs, and it is the one
most often made backwards: pass it when the icon is the control, leave it
off when there is text beside it. An unlabelled icon button is announced as
"button"; a labelled icon next to its own visible text is the same word read
twice.
uf:icon/… rather than ./star.svg
.svg stays Vite's. Claiming the extension would take it from
vite-plugin-svgr and everything like it, which is red line 8: if Vite can do
it, a uf project can do it. So icons live in uf's own uf: namespace and take
nothing away — import url from "./star.svg" still means exactly what it
always did.
What an icon may contain
An icon is inlined into your document and can come from a dependency, so uf
refuses rather than strips: a <script>, a <foreignObject>, an on…
attribute, a javascript: value, or a reference to another origin makes the
import fail with a message naming the file. Stripping would render something
different from the file in your repository and nobody would find out until it
looked wrong.
Everything else passes through unchanged, with one rewrite: every id inside an
icon, and every url(#…) and href="#…" that points at one, is prefixed with
the symbol's own id. Two icons that both define a gradient called a is the
normal case, and in one document the second would win for both.
An Open Graph card
import { OgImage } from "@uniflowed/web";
import card from "./guide.og.json";
export component Head() {
return <OgImage card={card} origin="https://example.com" />;
}
guide.og.json is a template, not a document. The compound extension is
the whole of what uf claims: ordinary .json stays Vite's, and
./guide.og.json?raw still reaches the file.
{
"eyebrow": "Writing code",
"title": "Images, fonts, icons and cards",
"subtitle": "Everything the build knows and the runtime cannot",
"background": { "from": "#0b1020", "to": "#161d3d" },
"foreground": "#f8fafc",
"accent": "#7c8cff",
"font": "../../brand/Inter-SemiBold.ttf"
}
uf draws it at 1200x630 — the ratio every consumer crops to — content-hashes the
PNG, and the import evaluates to its URL, size and alt text. OgImage writes the
tags, including the two things everybody gets wrong: an absolute URL, because
a relative og:image is not an Open Graph image at all, and
twitter:card: summary_large_image, without which X renders a thumbnail of a
1200-pixel picture. A project using @uniflowed/router's Metadata should pass
the import through openGraph.images instead and let its metadataBase do the
absolute URL.
This is a template, and deliberately not a renderer
The obvious version of this feature is "an image from JSX", and uf does not do
it. Rendering a document to an image is a CSS layout engine — flow, flex, grid,
line-height, text-overflow — and the two ways to have one are to ship a
browser or to approximate it. A browser is a dependency uf refuses to make
building an application need. An approximation produces cards that are subtly
wrong, and nobody looks at an Open Graph card until it is on somebody else's
website.
So: a fixed arrangement — background, rule, eyebrow, title, subtitle — drawn
natively with ab_glyph, which turns
a glyph into an outline and an outline into coverage. That covers most Open
Graph images and it is honest about not being JSX. A card that needs more is a
PNG you draw yourself, and Image has rendered one since the pipeline existed.
What it refuses, and why refusing is the feature
ab_glyph does no shaping, so uf places glyphs left to right at their
advance widths with the font's legacy kern. For Latin, Greek, Cyrillic, CJK
and the punctuation around them that is correct. For anything else it is not,
and uf fails the build with a message naming the character rather than
drawing it:
- right-to-left scripts — Hebrew, Arabic, Syriac, Thaana, N'Ko — which are reordered, and in Arabic joined, by a shaping engine;
- Brahmic scripts — Devanagari through Sinhala — whose vowel signs move around their consonant;
- Thai, Lao, Khmer, Myanmar, which do not separate words with spaces, so there is nothing to break lines on;
- combining marks, which are placed over their base by
GPOS; - a zero-width joiner or a variation selector, each of which says several code points are one glyph;
- a character the font has no glyph for, because drawing
.notdefgives a row of empty boxes that reads as a bug in whatever is showing the card; - a character the font draws from a colour or bitmap table —
COLR,sbix,CBDT— which is what an emoji usually is. That one is asked of the font rather than guessed from the character's block, so a check mark or an arrow your font has an outline for is drawn rather than refused for living near the emoji, and an emoji it has only in colour is refused rather than advanced past as a gap of exactly the right width.
A card that is wrong is worse than a card that does not exist, because the build said it succeeded.
uf also ships no font. A template with text and no font is refused: a
toolchain that carried a default typeface would carry a licence with it. Point
app.builtins.og.font at one and your templates stay text and colour.
Configuration
export default defineConfig({
app: {
builtins: {
images: {
widths: [640, 750, 828, 1080, 1200, 1440, 1920],
quality: 75,
placeholder: true,
remotePatterns: [],
qualities: [],
dangerouslyAllowPrivateAddresses: false,
},
fonts: { display: "swap", fallback: "Arial", subset: "none", preload: true },
icons: { dir: "icons" },
og: { font: null },
},
},
});
Those are the defaults. widths and quality are also the only width and
quality the request-time endpoint answers, together with anything in
qualities: every other value would be one more encode of every image a
stranger can name. transformer has no default. fallback is the local face uf scales, and it has to be
one uf has measured — Arial, Times New Roman or Courier New. A face uf
cannot measure is a face uf declines to scale, and it says so rather than
guessing: a confident, wrong size-adjust moves text just as much as none.
uf explain build prints what the stage will do with your settings.
Development and the build agree
There is one pipeline. uf dev and uf build both drive uf assets with the
same parameters, both write to the same cache under .uf/cache/assets, and both
get the same files under the same names. A dev session warms a cache the build
reuses. The only thing that differs is the URL prefix they are served under.
The cache
Every emitted file is named by a digest of its source and its parameters, so a rebuild writes the same bytes to the same path and nothing downstream re-downloads. That makes the output incremental and left the work untouched: the second build still decoded, resized and re-encoded everything before writing what was already there.
It does not any more. The manifest is cached beside the files under the same digest, so a second build of an unchanged project reads one small JSON document per asset and decodes, resizes, subsets and rasterises nothing.
There is no invalidation step because there is nothing to invalidate: the key
is the digest of every input. Change the image, a width, the quality, the
family, the subset mode or the base URL and the key changes; a stale entry
cannot be read because nothing asks for it. The one thing a key cannot cover is
the files, so a hit is only a hit when every file the manifest names is still on
disk — rm -rf dist with .uf/cache left behind redoes the work rather than
producing a build that names files nobody can serve.
The sprite is the exception: it is assembled once per build, after every module has been loaded, because that is the first moment the set of icons the build reached is the whole set.
What this does not do
The encoders in uf are pure Rust — no C library, no native toolchain, nothing
to install. That has a price, and it is worth stating plainly rather than
letting you discover it in a Lighthouse report. The same decisions are recorded
as refusals in What uf does not do; #997
is the issue that stopped treating them as open-ended gaps:
- No AVIF at build time.
uflinks an AV1 encoder —rav1e, pure Rust, throughravif— for the request-time endpoint, where a lossy modern format is the point. The build does not use it yet: it keeps an alternative format only when it is smaller at every width, and adding a candidate changes what every existing project's build writes, which deserves its own measurement. - No lossy WebP. There is no pure-Rust encoder for it under a licence uf can
ship;
libwebpis C.
So the modern format uf can emit is lossless WebP, and where that wins is not obvious: a logo or a gradient comes out roughly half the size of the PNG, a photograph comes out about the same or worse, and a UI screenshot resampled to a narrow width comes out seven times larger, because resampling turns hard edges into rings of near-colours that PNG's adaptive filtering absorbs and this encoder does not.
uf therefore does not guess. It encodes both at every width, and offers the WebP
only when it is smaller at every width — all the rungs or none of them,
because a <picture> source carrying only the wide rungs sends a phone a wide
one. When the WebP loses, no WebP file is written, and the byte counts that
decided it are on the import as declined.
A photograph therefore gets several JPEG widths and no <source>. That is the
correct answer, and you can see the numbers behind it.
- A subsetted face is emitted as WOFF 1.0, not WOFF2. uf has a WOFF2
reader and no encoder, and writing one whose only proof of correctness is a
browser CI cannot run is not a trade worth making. Brotli beats zlib by about
a fifth on a font; subsetting a family to one script removes rather more than
that, and WOFF 1.0 is readable by every browser that has
@font-faceat all. - Subsetting needs a
.ttf,.otfor.woffwith TrueType outlines. A.woff2stores itsglyftable in a transformed form uf does not reverse — point the import at the file the.woff2was built from. A font with CFF outlines is refused becauseskeradoes not rebuildCFF, and a CFF font that went through it would come back with no glyphs at all. Both refusals leave the whole font self-hosted and working, with the reason on the import assubsetDeclined. - One weight per import. Every imported font is declared at
font-weight: 400; font-style: normal, and the family is the file's stem. SoInter-Bold.woff2becomes the familyInter-Boldrather thanInterat 700, andfont-weight: boldwill not switch to it — use its ownfontFamily. A variable font, which is one file covering the range, is unaffected. Theuf assetsprotocol already carriesweightandstyle; what is missing is a way for an import to say them. - The request-time endpoint answers remote images only, and only from
the hosts you list. An image in
public/or one a module imports is the build's work, and a relativeurlis refused rather than fetched back from the same server. Nothing is fetched untilremotePatternsnames a host: an endpoint that resizes whatever a query string names is a denial-of-service amplifier, which is why the allow-list arrived in the same commit as the endpoint. See security.
An image uf has no decoder for — a GIF, an existing AVIF — is copied through
unchanged, and the import says so in its note rather than pretending it was
resized. An existing WebP is copied through as well but is measured, so the
page still gets the intrinsic size that reserves its box.
.svg is not claimed at all. uf could only ever copy one through — it is already
resolution independent — and claiming the extension would take it away from
vite-plugin-svgr and everything like it. An SVG import stays Vite's, and
<Image src={url} width={…} height={…} /> renders one.
Reaching past it
An import with a query is Vite's, not uf's:
import url from "./hero.png?url";
uf claims the plain form and leaves every Vite asset convention — ?url,
?raw, ?inline — exactly where it was.
Where to go next
Caching is next: a route cache and a fetch cache, both off until a route asks.
Edit this pagedocs/app/guide/assets/$page.mdx