0040. Serve widget fonts as hashed, CORS-enabled assets¶
Date: 2026-10-05 Status: Accepted
Context¶
Both embeddable widgets, the configuration widget (widget.js) and the connector widget
(connector-widget.js), use four self-hosted Inter weights (about 133 KB together). They have to
be self-hosted: Google's and emailsys's web delivery strip the OpenType zero feature (slashed
zero) that our subset files keep.
Vite builds both widgets in library mode, which inlines every imported asset as base64 regardless
of assetsInlineLimit. Each bundle therefore carried about 178 KB of font data, the same data in
both bundles. The bundles have fixed names without a hash, so every widget release made browsers
download the unchanged fonts again.
Decision¶
The fonts are separate files with a content hash in their name, served by nginx with CORS and immutable caching.
- The shared helper
resources/widget-shared/registerInterFont.jsimports the fonts with?no-inline. Vite emits them topublic/widget-assets/[name]-[hash][extname]. - Both widget configs set
base: './'. The bundle resolves the font URLs at runtime against its own<script>URL (document.currentScript.src), so the fonts load from the Conflux origin, not from the page the widget is embedded in. The URLs are built at module top level, while the script is executing; later,document.currentScriptisnull. - Both builds emit identical files, so both widgets share one cached copy.
- nginx serves
/widget-assets/withAccess-Control-Allow-Origin: *andCache-Control: public, max-age=31536000, immutable. Browsers fetch fonts in CORS mode, and the permission can only come from the server that delivers the file.*is acceptable because the files are public and the embedding origins are not known in advance. The CORS header is sent withalways, so a missing file reaches the host page as a 404 instead of a CORS error;Cache-Controlis not, so an error response is never cached as immutable. - The FontFace API registration and the namespaced family names stay unchanged.
Rejected:
- Fixed files in
public/with a manual version in the name. Same delivery, but cache busting becomes manual work; a forgotten version bump serves stale fonts for a year. - A Laravel route serving the fonts. Needs no nginx change, but runs PHP-FPM for every uncached font request, which defeats the purpose.
- A public font CDN. Strips the
zerofeature.
Consequences¶
- The bundles are about 178 KB smaller each. The widget renders with its fallback font first and switches to Inter once a weight has loaded.
- The nginx in front of
public/must send the two headers for/widget-assets/: locally in.docker/nginx, on staging inconflux-infrastructure. Alocationthat setsadd_headerdoes not inherit the server block's headers, so the block repeats them. Without the CORS header the widgets fall back to the system font; nothing else breaks. - The infrastructure change has to be deployed before the widget change, or staging shows the fallback font until it is.
- Host pages with a Content Security Policy need
font-srcwith the Conflux origin (previouslyfont-src data:). - The nginx headers are bound to the path
/widget-assets/at the web root. If the bundles ever move to another path, the relative font URLs follow them but thelocationblock does not, so it has to move too. - Placing a CDN in front of Conflux later needs no widget change: the CDN passes the origin's headers through, and the URLs follow the script's origin.
widget.jsandconnector-widget.jsthemselves are still unhashed. Making them cacheable needs a loader with a fixed name in front of hashed bundles, which is a separate decision.widget.jsandconnector-widget.jsare served withoutCache-Control, so browsers cache them heuristically. If the font files change, a stale cached bundle keeps requesting the old hash, which a new release no longer has, and shows the fallback font until its cache expires.