Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ec68468
feat(fonts): experimental_getFontBuffer()
florian-lefebvre Apr 13, 2026
331649b
fix
florian-lefebvre Apr 13, 2026
7b8fafd
test core
florian-lefebvre Apr 13, 2026
fce29f3
async
florian-lefebvre Apr 13, 2026
56f7192
simplify
florian-lefebvre Apr 13, 2026
6d3f0f3
test
florian-lefebvre Apr 13, 2026
575df21
infra test
florian-lefebvre Apr 13, 2026
327062e
fix
florian-lefebvre Apr 13, 2026
3b65458
error
florian-lefebvre Apr 13, 2026
198fff4
chore: format
florian-lefebvre Apr 13, 2026
64e124d
changeset
florian-lefebvre Apr 13, 2026
12a1ab2
fix
florian-lefebvre Apr 13, 2026
2b10a1d
Apply suggestions from code review
florian-lefebvre Apr 13, 2026
fc89dba
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre Apr 15, 2026
a1f5cf7
update
florian-lefebvre Apr 15, 2026
04e7396
reduce diff
florian-lefebvre Apr 16, 2026
1cf050a
fetch
florian-lefebvre Apr 16, 2026
1aca8eb
document
florian-lefebvre Apr 16, 2026
6cb9425
use full address
florian-lefebvre Apr 16, 2026
4add883
simplify
florian-lefebvre Apr 16, 2026
6fd545e
chore: format
florian-lefebvre Apr 16, 2026
c23f70e
error handling
florian-lefebvre Apr 16, 2026
a3212db
feat: stronger validation
florian-lefebvre Apr 16, 2026
767a616
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre Apr 16, 2026
b335b91
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre Apr 16, 2026
f5482f5
fix: lint
florian-lefebvre Apr 16, 2026
20aeb80
feedback
florian-lefebvre Apr 17, 2026
0aaf5f8
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre Apr 17, 2026
c8c2e22
rename
florian-lefebvre Apr 17, 2026
3e99ee7
changeset
florian-lefebvre Apr 17, 2026
85de653
improve changeset
florian-lefebvre Apr 17, 2026
2518318
Update packages/astro/src/core/errors/errors-data.ts
florian-lefebvre Apr 20, 2026
164e270
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre Apr 20, 2026
d5e6f45
Merge branch 'main' into feat/fonts-experimental-get-font-buffer
florian-lefebvre Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/wacky-carrots-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
'astro': minor
---

Adds a new `experimental_getFontFileURL()` method to resolve font file URLs when using the Fonts API

The `fontData` object exported from `astro:assets` was introduced to provide low-level access to font family data for advanced usage. One of the goals of this API was to be able to resolve buffers using URLs. However, it turned out to be impractical, especially during prerendering.

Astro now exports a new `experimental_getFontFileURL()` helper function from `astro:assets` to resolve font file URLs from `fontData`. For example, when using [satori](https://github.com/vercel/satori) to generate Open Graph images:

```diff
// src/pages/og.png.ts

import type { APIRoute } from "astro";
-import { fontData } from "astro:assets";
+import { fontData, experimental_getFontFileURL } from "astro:assets";
-import { outDir } from "astro:config/server";
-import { readFile } from "node:fs/promises";
import satori from "satori";
import { html } from "satori-html";
import sharp from "sharp";

export const GET: APIRoute = async (context) => {
const fontPath = fontData["--font-roboto"][0]?.src[0]?.url;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need to extract the URL like this, what is the benefit of the experimental_getFontBuffer function vs. just calling fetch() yourself?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this point specifically, the purpose of getFontBuffer() is to avoid:

  const data = import.meta.env.DEV
    ? await fetch(new URL(fontPath, context.url.origin)).then(async (res) => res.arrayBuffer())
    : await readFile(new URL(`.${fontPath}`, outDir));

Because, on the user side, fetch() would only work if you're in dev mode or if there is an existing live website. If your website is not live yet, the build fails and you have to read the file locally.

But, this example doesn't work for all use cases. Depending on the build output, users have to use outDir or build.client (with the Node adapter at least) to resolve the right path. The getFontBuffer() helper is meant to simplify that FWIU.

However, yeah, this doesn't solve all the "DX issues".
I mean fontData["--font-roboto"][0]?.src[0]?.url is not nice. That's said, this is hard to know what the user wants. With multiple fonts and/or a multiple weights you might end up with something like fontData["--font-inter"].find((font) => font.style === "normal" && font.weight === "400")?.src[0]?.url;.

Perhaps it's possible to add options to filter on behalf of the user? But getFontBuffer() then do two things and may require renaming.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes again the problem this API is solving is access to font files during the build, which is one of the main usecases. And for this fetch() is the ideal solution

Having a great DX around how to access the URL to pass to getFontBuffer() is a different topic on its own I think. I would like this PR to be low-level and then we can think about improving the DX (this was discussed on Discord a few months back) in another PR. We're quite free to experiment with it while it's in experimental


if (fontPath === undefined) {
throw new Error("Cannot find the font path.");
}

- const data = import.meta.env.DEV
- ? await fetch(new URL(fontPath, context.url.origin)).then(async (res) => res.arrayBuffer())
- : await readFile(new URL(`.${fontPath}`, outDir));
+ const url = experimental_getFontFileURL(fontPath, context.url);
+ const data = await fetch(url).then((res) => res.arrayBuffer());

const svg = await satori(
html`<div style="color: black;">hello, world</div>`,
{
width: 600,
height: 400,
fonts: [
{
name: "Roboto",
data,
weight: 400,
style: "normal",
},
],
},
);

const pngBuffer = await sharp(Buffer.from(svg))
.resize(600, 400)
.png()
.toBuffer();

return new Response(new Uint8Array(pngBuffer), {
headers: {
"Content-Type": "image/png",
},
});
};
```

See the [Fonts API documentation](https://docs.astro.build/en/guides/fonts/#accessing-font-data-programmatically) for more information.
1 change: 1 addition & 0 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ declare module 'astro:assets' {
import('astro:assets').CssVariable,
Array<import('astro:assets').FontData>
>;
export const experimental_getFontFileURL: typeof import('./dist/assets/fonts/runtime.js').experimental_getFontFileURL;

type ImgAttributes = import('./dist/type-utils.js').WithRequired<
Omit<import('./types').HTMLAttributes<'img'>, 'src' | 'width' | 'height'>,
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/dev-only.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ declare module 'virtual:astro:assets/fonts/internal' {
export const fontDataByCssVariable: import('./src/assets/fonts/types.js').FontDataByCssVariable;
}

declare module 'virtual:astro:assets/fonts/runtime/font-file-url-resolver' {
export const runtimeFontFileUrlResolver: import('./src/assets/fonts/definitions.js').RuntimeFontFileUrlResolver;
}

declare module 'virtual:astro:adapter-config/client' {
export const internalFetchHeaders: Record<string, string>;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/assets/fonts/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
export const RUNTIME_VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/runtime';
export const RESOLVED_RUNTIME_VIRTUAL_MODULE_ID = '\0' + RUNTIME_VIRTUAL_MODULE_ID;

export const RUNTIME_FONT_FILE_URL_RESOLVER_VIRTUAL_MODULE_ID =
'virtual:astro:assets/fonts/runtime/font-file-url-resolver';
export const RESOLVED_RUNTIME_FONT_FILE_URL_RESOLVER_VIRTUAL_MODULE_ID =
'\0' + RUNTIME_FONT_FILE_URL_RESOLVER_VIRTUAL_MODULE_ID;

export const ASSETS_DIR = 'fonts';
export const CACHE_DIR = './fonts/';

Expand Down
22 changes: 22 additions & 0 deletions packages/astro/src/assets/fonts/core/create-get-font-file-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import type { RuntimeFontFileUrlResolver } from '../definitions.js';

export function createGetFontFileURL(runtimeFontFileUrlResolver: RuntimeFontFileUrlResolver) {
return function getFontFileURL(url: string, requestUrl?: URL): string {
try {
const result = runtimeFontFileUrlResolver.resolve(url, requestUrl);
if (result === null) {
throw new Error('Not found');
}
return result;
} catch (cause) {
throw new AstroError(
{
...AstroErrorData.FontFileUrlNotFound,
message: AstroErrorData.FontFileUrlNotFound.message(url),
},
{ cause },
);
}
};
}
82 changes: 82 additions & 0 deletions packages/astro/src/assets/fonts/core/font-file-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { AstroLogger } from '../../../core/logger/core.js';
import type { FontFetcher, FontTypeExtractor } from '../definitions.js';
import type { FontFileById } from '../types.js';
import { isAstroError } from '../../../core/errors/errors.js';
import { formatErrorMessage } from '../../../core/messages/runtime.js';
import { collectErrorMetadata } from '../../../core/errors/dev/utils.js';
import type { ServerResponse } from 'node:http';

interface MinimalResponse {
setHeader: (name: string, value: string) => void;
end: (buffer?: Buffer) => void;
setStatusCode: (statusCode: number) => void;
}

interface Options {
url: string | undefined;
response: MinimalResponse;
next: () => void;
fontFetcher: FontFetcher | null;
fontTypeExtractor: FontTypeExtractor | null;
logger: AstroLogger;
fontFileById: FontFileById | null;
}

export function resToMinimalResponse(res: ServerResponse): MinimalResponse {
return {
setHeader: (...args) => res.setHeader(...args),
end: (...args) => res.end(...args),
setStatusCode: (statusCode) => {
res.statusCode = statusCode;
},
};
}

export async function fontFileMiddleware({
url: _url,
response,
next,
fontFetcher,
fontTypeExtractor,
logger,
fontFileById,
}: Options): Promise<void> {
if (!fontFetcher || !fontTypeExtractor || !fontFileById) {
logger.debug('assets', 'Fonts dependencies should be initialized by now, skipping middleware.');
return next();
}
if (!_url) {
return next();
}
const url = new URL(_url, 'http://localhost');
const fontId = url.pathname.slice(1);
const fontData = fontFileById.get(fontId);
if (!fontData) {
return next();
}
// We don't want the request to be cached in dev because we cache it already internally,
// and it makes it easier to debug without needing hard refreshes
response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
response.setHeader('Pragma', 'no-cache');
response.setHeader('Expires', '0');

try {
const buffer = await fontFetcher.fetch({ id: fontId, ...fontData });

response.setHeader('Content-Length', buffer.byteLength.toString());
response.setHeader('Content-Type', `font/${fontTypeExtractor.extract(fontId)}`);

response.setStatusCode(200);
response.end(buffer);
} catch (err) {
logger.error('assets', 'Cannot download font file');
if (isAstroError(err)) {
logger.error(
'SKIP_FORMAT',
formatErrorMessage(collectErrorMetadata(err), logger.level() === 'debug'),
);
}
response.setStatusCode(500);
response.end();
}
}
14 changes: 14 additions & 0 deletions packages/astro/src/assets/fonts/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface Hasher {
export interface UrlResolver {
resolve: (id: string) => string;
readonly cspResources: Array<string>;
readonly urls: Array<string>;
}

export interface FontFileContentResolver {
Expand Down Expand Up @@ -87,3 +88,16 @@ export interface FontResolver {
) => Promise<Array<unifont.FontFaceData>>;
listFonts: (options: { provider: FontProvider }) => Promise<string[] | undefined>;
}

export interface RuntimeFontFileUrlResolver {
/**
* @param url
* URL obtained from `fontData` and provided by the user. Can look like
* `/_astro/fonts/<hash>.<ext>` or be a full URL when using assetsPrefix.
*
* @param requestUrl
* The current request URL. It can be used to construct a full URL to the
* font file, for example in SSR.
*/
resolve: (url: string, requestUrl: URL | undefined) => string | null;
}
9 changes: 8 additions & 1 deletion packages/astro/src/assets/fonts/infra/build-url-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { UrlResolver } from '../definitions.js';

export class BuildUrlResolver implements UrlResolver {
readonly #resources = new Set<string>();
readonly #urls = new Set<string>();
readonly #base: string;
readonly #assetsPrefix: AssetsPrefix;
readonly #searchParams: URLSearchParams;
Expand Down Expand Up @@ -43,10 +44,16 @@ export class BuildUrlResolver implements UrlResolver {
url.searchParams.set(key, value);
});

return stringifyPlaceholderURL(url);
const result = stringifyPlaceholderURL(url);
this.#urls.add(result);
return result;
}

get cspResources(): Array<string> {
return Array.from(this.#resources);
}

get urls(): Array<string> {
return Array.from(this.#urls);
}
}
9 changes: 8 additions & 1 deletion packages/astro/src/assets/fonts/infra/dev-url-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createPlaceholderURL, stringifyPlaceholderURL } from '../../utils/url.j
import type { UrlResolver } from '../definitions.js';

export class DevUrlResolver implements UrlResolver {
readonly #urls = new Set<string>();
#resolved = false;
readonly #base: string;
readonly #searchParams: URLSearchParams;
Expand All @@ -28,10 +29,16 @@ export class DevUrlResolver implements UrlResolver {
url.searchParams.set(key, value);
});

return stringifyPlaceholderURL(url);
const result = stringifyPlaceholderURL(url);
this.#urls.add(result);
return result;
}

get cspResources(): Array<string> {
return this.#resolved ? ["'self'"] : [];
}

get urls(): Array<string> {
return Array.from(this.#urls);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { AddressInfo } from 'node:net';
import type { RuntimeFontFileUrlResolver } from '../definitions.js';

/**
* In development, font files are served through a Vite middleware.
* During prerendering, a temporary Node HTTP server is started to
* serve font files.
*
* We send request to the provided server address. `requestUrl` on
* `fetch` is not implemented because we have the information from
* within the Vite plugin already.
*/
export class RemoteRuntimeFontFileUrlResolver implements RuntimeFontFileUrlResolver {
#urls: Set<string>;
#address: AddressInfo | null;

constructor({
urls,
address,
}: {
urls: Set<string>;
address: AddressInfo | null;
}) {
this.#urls = urls;
this.#address = address;
}

resolve(url: string): string | null {
if (!this.#urls.has(url)) {
return null;
}
if (!this.#address) {
throw new Error('Server address unavailable, this should not happen. Open an issue.');
}
// assetsPrefix
if (!url.startsWith('/')) {
url = new URL(url).pathname;
}
const host =
this.#address.family === 'IPv6' ? `[${this.#address.address}]` : this.#address.address;
return `http://${host}:${this.#address.port}${url}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { RuntimeFontFileUrlResolver } from '../definitions.js';
import { MissingGetFontFileRequestUrl } from '../../../core/errors/errors-data.js';
import { AstroError } from '../../../core/errors/errors.js';

/**
* During SSR, we don't know ahead of time where the server is located.
* We rely on `requestUrl` (provided by the user) to construct the URL.
*/
export class SsrRuntimeFontFileUrlResolver implements RuntimeFontFileUrlResolver {
#urls: Set<string>;

constructor({
urls,
}: {
urls: Set<string>;
}) {
this.#urls = urls;
}

resolve(url: string, requestUrl: URL | undefined): string | null {
if (!this.#urls.has(url)) {
Comment thread
florian-lefebvre marked this conversation as resolved.
return null;
}
// assetsPrefix
if (!url.startsWith('/')) {
return url;
}
// We need the request URL to call the current server
if (!requestUrl) {
throw new AstroError(MissingGetFontFileRequestUrl);
}
return `${requestUrl.origin}${url}`;
}
}
3 changes: 3 additions & 0 deletions packages/astro/src/assets/fonts/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { fontDataByCssVariable } from 'virtual:astro:assets/fonts/internal';
import { runtimeFontFileUrlResolver } from 'virtual:astro:assets/fonts/runtime/font-file-url-resolver';
import { createGetFontFileURL } from './core/create-get-font-file-url.js';

export const fontData = fontDataByCssVariable;
export const experimental_getFontFileURL = createGetFontFileURL(runtimeFontFileUrlResolver);
Loading
Loading