Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
64 changes: 64 additions & 0 deletions .changeset/wacky-carrots-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
'astro': minor
---

Adds a new `experimental_getFontBuffer()` method to retrieve font file buffers 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 retrieve buffers using URLs. However, it turned out to be impractical, especially during prerendering.

Astro now exports a new `experimental_getFontBuffer()` helper function from `astro:assets` to retrieve font file buffers from URL obtained via `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_getFontBuffer } 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 data = await experimental_getFontBuffer(fontPath, context.url)
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated

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_getFontBuffer: typeof import('./dist/assets/fonts/runtime.js').experimental_getFontBuffer;

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-fetcher' {
export const runtimeFontFetcher: import('./src/assets/fonts/definitions.js').RuntimeFontFetcher;
}

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_FETCHER_VIRTUAL_MODULE_ID =
'virtual:astro:assets/fonts/runtime/font-fetcher';
export const RESOLVED_RUNTIME_FONT_FETCHER_VIRTUAL_MODULE_ID =
'\0' + RUNTIME_FONT_FETCHER_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-buffer.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 { RuntimeFontFetcher } from './../definitions.js';

export function createGetFontBuffer(runtimeFontFetcher: RuntimeFontFetcher) {
return async function getFontBuffer(url: string, requestUrl?: URL): Promise<ArrayBuffer> {
try {
const buffer = await runtimeFontFetcher.fetch(url, requestUrl);
if (buffer === null) {
throw new Error('Not found');
}
return buffer;
} catch (cause) {
throw new AstroError(
{
...AstroErrorData.FontBufferNotFound,
message: AstroErrorData.FontBufferNotFound.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();
}
}
4 changes: 4 additions & 0 deletions packages/astro/src/assets/fonts/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,7 @@ export interface FontResolver {
) => Promise<Array<unifont.FontFaceData>>;
listFonts: (options: { provider: FontProvider }) => Promise<string[] | undefined>;
}

export interface RuntimeFontFetcher {
fetch: (url: string, requestUrl: URL | undefined) => Promise<ArrayBuffer | null>;
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { RuntimeFontFetcher } from '../definitions.js';

export class BuildRuntimeFontFetcher implements RuntimeFontFetcher {
#ids: Set<string>;
#port: number;
#fetch: typeof globalThis.fetch;

constructor({
ids,
port,
fetch,
}: {
ids: Set<string>;
port: number;
fetch: typeof globalThis.fetch;
}) {
this.#ids = ids;
this.#port = port;
this.#fetch = fetch;
}

async fetch(url: string): Promise<ArrayBuffer | null> {
const id = url.split('/').pop() ?? '';
if (!this.#ids.has(id)) {
return null;
}
return this.#fetch(`http://localhost:${this.#port}/${id}`).then((res) => res.arrayBuffer());
}
}
35 changes: 35 additions & 0 deletions packages/astro/src/assets/fonts/infra/dev-runtime-font-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { RuntimeFontFetcher } from '../definitions.js';

export class DevRuntimeFontFetcher implements RuntimeFontFetcher {
#ids: Set<string>;
#port: number;
#base: string;
#fetch: typeof globalThis.fetch;

constructor({
ids,
port,
base,
fetch,
}: {
ids: Set<string>;
port: number;
base: string;
fetch: typeof globalThis.fetch;
}) {
this.#ids = ids;
this.#port = port;
this.#base = base;
this.#fetch = fetch;
}

async fetch(url: string): Promise<ArrayBuffer | null> {
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
const id = url.split('/').pop() ?? '';
if (!this.#ids.has(id)) {
return null;
}
return this.#fetch(`http://localhost:${this.#port}${this.#base}${id}`).then((res) =>
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
res.arrayBuffer(),
);
}
}
33 changes: 33 additions & 0 deletions packages/astro/src/assets/fonts/infra/ssr-runtime-font-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { RuntimeFontFetcher } from '../definitions.js';
import { MissingGetFontBufferRequestUrl } from '../../../core/errors/errors-data.js';
import { AstroError } from '../../../core/errors/errors.js';

export class SsrRuntimeFontFetcher implements RuntimeFontFetcher {
#ids: Set<string>;
#fetch: typeof globalThis.fetch;

constructor({
ids,
fetch,
}: {
ids: Set<string>;
fetch: typeof globalThis.fetch;
}) {
this.#ids = ids;
this.#fetch = fetch;
}

async fetch(url: string, requestUrl: URL | undefined): Promise<ArrayBuffer | null> {
const id = url.split('/').pop() ?? '';
if (!this.#ids.has(id)) {
return null;
}
if (url.startsWith('http')) {
return this.#fetch(url).then((res) => res.arrayBuffer());
}
Comment thread
florian-lefebvre marked this conversation as resolved.
Outdated
if (!requestUrl) {
throw new AstroError(MissingGetFontBufferRequestUrl);
}
return this.#fetch(`${requestUrl.origin}${url}`).then((res) => res.arrayBuffer());
}
}
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 { runtimeFontFetcher } from 'virtual:astro:assets/fonts/runtime/font-fetcher';
import { createGetFontBuffer } from './core/create-get-font-buffer.js';

export const fontData = fontDataByCssVariable;
export const experimental_getFontBuffer = createGetFontBuffer(runtimeFontFetcher);
Loading
Loading