Skip to content

Commit c850dfd

Browse files
authored
fix(cloudflare): resolve fonts via localFetch when ASSETS binding unavailable (#527)
1 parent 6bd5388 commit c850dfd

File tree

5 files changed

+45
-34
lines changed

5 files changed

+45
-34
lines changed

src/module.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,7 +1159,7 @@ export const resolve = (import.meta.dev || import.meta.prerender) ? devResolve :
11591159
}
11601160
// Dev mode: convertWoff2ToTtf() may not have run via vite:compiled
11611161
// because OG components are lazily compiled. Run it now on first resolve.
1162-
if (!fontProcessingDone && convertedWoff2Files.size === 0 && (hasSatoriRenderer() || hasTakumiRenderer()) && hasNuxtFonts) {
1162+
if (!fontProcessingDone && convertedWoff2Files.size === 0 && hasSatoriRenderer() && hasNuxtFonts) {
11631163
if (pendingFontRequirements.length > 0)
11641164
await Promise.all(pendingFontRequirements)
11651165
await convertWoff2ToTtf({
@@ -1237,8 +1237,8 @@ export const tw4Colors = ${JSON.stringify(cssMetadata.colors)}`
12371237
export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
12381238
}
12391239

1240-
// @nuxt/fonts + satori/takumi font processing — convert WOFF2 to static TTF via fontless
1241-
// Needed for satori (can't use WOFF2) and takumi (WOFF2 subset decompression bugs)
1240+
// @nuxt/fonts + satori font processing — convert WOFF2 to static TTF/WOFF via fontless.
1241+
// Only needed for Satori which can't parse WOFF2. Takumi handles WOFF2 natively.
12421242
if (hasNuxtFonts) {
12431243
// Hook into @nuxt/fonts to persist font URL mapping for prerender
12441244
nuxt.hook('fonts:public-asset-context' as any, (ctx: { renderedFontURLs: Map<string, string> }) => {
@@ -1248,7 +1248,7 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
12481248
nuxt.hook('vite:compiled', async () => {
12491249
// Always persist font URL mapping (needed by all renderers for prerender/dev font resolution)
12501250
persistFontUrlMapping({ fontContext, buildDir: nuxt.options.buildDir, logger })
1251-
if (fontProcessingDone || (!hasSatoriRenderer() && !hasTakumiRenderer()))
1251+
if (fontProcessingDone || !hasSatoriRenderer())
12521252
return
12531253
// Skip until font requirements are populated (OG components are server-side,
12541254
// so onFontRequirements runs during the server Vite build, not the client build)

src/runtime/server/og-image/bindings/font-assets/cloudflare.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,19 @@ export async function resolve(event: H3Event, font: FontConfig) {
1919
}
2020
}
2121

22-
// Fallback: use event.$fetch for in-process resolution — avoids external HTTP
23-
// round-trips which cause self-referencing subrequest failures on CF Workers
24-
const internalFetch = (event as any).$fetch as ((path: string, opts: { responseType: string }) => Promise<ArrayBuffer>) | undefined
25-
if (typeof internalFetch === 'function') {
26-
const arrayBuffer = await internalFetch(fullPath, { responseType: 'arrayBuffer' })
27-
return Buffer.from(arrayBuffer)
22+
// Fallback: use event.fetch (Nitro localFetch) which routes through the h3 app
23+
// and can serve public assets from Nitro's built-in asset handler.
24+
// This is needed for Workers Static Assets (WSA) deployments where the ASSETS
25+
// binding is not injected into env (assets are served at the runtime level
26+
// before the worker's fetch runs).
27+
if (typeof event.fetch === 'function') {
28+
const origin = event.context.cloudflare?.request?.url || `https://${event.headers.get('host') || 'localhost'}`
29+
const url = new URL(fullPath, origin).href
30+
const res = await event.fetch(url).catch(() => null) as Response | null
31+
if (res?.ok) {
32+
return Buffer.from(await res.arrayBuffer())
33+
}
2834
}
2935

30-
throw new Error(`[Nuxt OG Image] Cannot resolve font "${font.family}" on Cloudflare Workers: no ASSETS binding or event.$fetch available. Ensure static assets are configured.`)
36+
throw new Error(`[Nuxt OG Image] Cannot resolve font "${font.family}" on Cloudflare Workers: no ASSETS binding or event.fetch available. Ensure static assets are configured.`)
3137
}

src/runtime/server/og-image/fonts.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,25 @@ import { codepointsIntersectRanges, parseUnicodeRange } from './unicode-range'
1010

1111
export { codepointsIntersectRanges, extractCodepoints, parseUnicodeRange } from './unicode-range'
1212

13+
type FontFormat = 'ttf' | 'otf' | 'woff' | 'woff2'
14+
15+
function fontFormat(src: string): FontFormat {
16+
if (src.endsWith('.woff2'))
17+
return 'woff2'
18+
if (src.endsWith('.woff'))
19+
return 'woff'
20+
if (src.endsWith('.otf'))
21+
return 'otf'
22+
return 'ttf'
23+
}
24+
1325
export interface LoadFontsOptions {
1426
/**
15-
* Whether the renderer supports WOFF2.
16-
* When false, WOFF2 fonts will use satoriSrc (converted TTF) if available,
17-
* otherwise they will be skipped.
18-
*/
19-
supportsWoff2: boolean
20-
/**
21-
* Prefer static (satoriSrc) fonts over WOFF2 when available.
22-
* Used by Takumi to avoid WOFF2 subset decompression bugs while still
23-
* falling through to WOFF2 when no static alternative exists.
27+
* Font formats the renderer can parse.
28+
* Satori: ttf, otf, woff
29+
* Takumi: ttf, woff2
2430
*/
25-
preferStatic?: boolean
31+
supportedFormats: Set<FontFormat>
2632
/** Component pascalName — filters fonts to only what this component needs */
2733
component?: string
2834
/** When set, ensures this font family is included even if not in requirements */
@@ -160,23 +166,22 @@ export async function loadAllFonts(event: H3Event, options: LoadFontsOptions): P
160166
const results = await Promise.all(
161167
fonts.map(async (f) => {
162168
let src = f.src
163-
const isWoff2 = f.src.endsWith('.woff2')
169+
const srcFormat = fontFormat(f.src)
164170

165-
if (isWoff2 && (options.preferStatic || !options.supportsWoff2)) {
166-
if (f.satoriSrc) {
167-
// Use static alternative (TTF/WOFF) downloaded by fontless
171+
// If the primary src format isn't supported, try satoriSrc as alternative
172+
if (!options.supportedFormats.has(srcFormat)) {
173+
if (f.satoriSrc && options.supportedFormats.has(fontFormat(f.satoriSrc))) {
168174
src = f.satoriSrc
169175
}
170-
else if (!options.supportsWoff2) {
171-
// Satori: can't use WOFF2 at all, skip this font
176+
else {
177+
// No usable format available, skip this font
172178
return null
173179
}
174-
// Takumi (preferStatic + supportsWoff2): fall through to WOFF2 — better than nothing
175180
}
176181

177182
let data = await loadFont(event, f, src)
178-
// When satoriSrc fails to load (e.g. 404), try original WOFF2 for renderers that support it
179-
if (!data && src !== f.src && options.supportsWoff2) {
183+
// When satoriSrc fails to load (e.g. 404), try original src if its format is supported
184+
if (!data && src !== f.src && options.supportedFormats.has(srcFormat)) {
180185
data = await loadFont(event, f, f.src)
181186
if (data)
182187
src = f.src
@@ -203,7 +208,7 @@ export async function loadAllFonts(event: H3Event, options: LoadFontsOptions): P
203208
const isCommunity = options.component && (map as Record<string, any>)[options.component]?.category === 'community'
204209

205210
// Warn when rendering with Satori and only variable fonts are available (deduplicated)
206-
if (!options.supportsWoff2 && loaded.length === 0 && fonts.length > 0 && !isCommunity) {
211+
if (!options.supportedFormats.has('woff2') && loaded.length === 0 && fonts.length > 0 && !isCommunity) {
207212
const variableFamilies = [...new Set(fonts.map(f => f.family))]
208213
const warnKey = `variable-fonts-${variableFamilies.join(',')}`
209214
if (!_warnedFontKeys.has(warnKey)) {

src/runtime/server/og-image/satori/renderer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export async function createSvg(event: OgImageRenderEventContext): Promise<{ svg
4545
const codepoints = extractCodepoints(vnodes)
4646
const hasCustomFonts = Array.isArray(options.fonts) && options.fonts.length > 0
4747
const fonts = await loadFontsForRenderer(event, {
48-
supportsWoff2: false,
48+
supportedFormats: new Set(['ttf', 'otf', 'woff'] as const),
4949
component: options.component,
5050
fontFamilyOverride: fontFamilyOverride || defaultFont,
5151
codepoints,

src/runtime/server/og-image/takumi/renderer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp
102102
const { fontFamilyOverride, defaultFont } = getDefaultFontFamily(options)
103103
const nodes = await createTakumiNodes(event)
104104
const codepoints = extractCodepoints(nodes)
105-
const fonts = await loadFontsForRenderer(event, { supportsWoff2: true, preferStatic: true, component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints })
105+
const fonts = await loadFontsForRenderer(event, { supportedFormats: new Set(['ttf', 'woff2'] as const), component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints })
106106

107107
await event._nitro.hooks.callHook('nuxt-og-image:takumi:nodes' as any, nodes, event)
108108

@@ -175,7 +175,7 @@ const TakumiRenderer: Renderer = {
175175
async debug(e) {
176176
const [vnodes, fonts] = await Promise.all([
177177
createTakumiNodes(e),
178-
loadFontsForRenderer(e, { supportsWoff2: true, preferStatic: true, component: e.options.component }),
178+
loadFontsForRenderer(e, { supportedFormats: new Set(['ttf', 'woff2'] as const), component: e.options.component }),
179179
])
180180
return {
181181
vnodes,

0 commit comments

Comments
 (0)