Skip to content

Commit 6ae9a61

Browse files
authored
fix: add image fetch timeouts and Server-Timing instrumentation (#581)
1 parent 6267192 commit 6ae9a61

16 files changed

Lines changed: 545 additions & 218 deletions

File tree

src/module.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,17 @@ export interface ModuleOptions {
208208
maxDpr?: number
209209
/** Render timeout in milliseconds. Returns 408 on timeout. @default 15000 */
210210
renderTimeout?: number
211+
/**
212+
* Per-image fetch timeout in milliseconds.
213+
*
214+
* Applies to remote `<img>` / `background-image` fetches done while resolving templates.
215+
* A stalled resource can otherwise consume the entire `renderTimeout` budget, which
216+
* shows up as near-total request timeouts on edge platforms (e.g. Cloudflare Workers
217+
* when the image URL routes back through the same worker).
218+
*
219+
* @default 3000
220+
*/
221+
imageFetchTimeout?: number
211222
/**
212223
* Maximum allowed length (in characters) for the query string on runtime OG image requests.
213224
* Requests exceeding this limit receive a 400 response.
@@ -1530,6 +1541,7 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
15301541
maxDimension: config.security?.maxDimension ?? 2048,
15311542
maxDpr: config.security?.maxDpr ?? 2,
15321543
renderTimeout: config.security?.renderTimeout ?? 15_000,
1544+
imageFetchTimeout: config.security?.imageFetchTimeout ?? 3_000,
15331545
maxQueryParamSize: config.security?.maxQueryParamSize ?? (config.security?.strict ? 2048 : null),
15341546
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin === true || (config.security?.strict && config.security?.restrictRuntimeImagesToOrigin == null)
15351547
? []

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

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,22 @@ import type { H3Event } from 'h3'
22
import type { FontConfig } from '../../../../types'
33
import { useRuntimeConfig } from 'nitropack/runtime'
44
import { withBase } from 'ufo'
5+
import { getCloudflareAssets } from '../../../util/cloudflareAssets'
6+
import { fetchLocalAsset } from '../../../util/fetchLocalAsset'
7+
import { getFetchTimeout } from '../../../util/fetchTimeout'
8+
import { useOgImageRuntimeConfig } from '../../../utils'
59

610
export async function resolve(event: H3Event, font: FontConfig) {
711
const path = font.src || font.localPath
812
const { app } = useRuntimeConfig()
913
const fullPath = withBase(path, app.baseURL)
14+
const timeout = getFetchTimeout(useOgImageRuntimeConfig())
1015

11-
// Try ASSETS binding first (Cloudflare Pages / Workers with static assets)
12-
// This is the recommended approach and requires either:
13-
// - nitro.cloudflare.deployConfig: true (generates wrangler.json with ASSETS binding)
14-
// - A wrangler.toml/json with [assets] configured
15-
const assets = event.context.cloudflare?.env?.ASSETS || event.context.ASSETS
16-
if (assets && typeof assets.fetch === 'function') {
17-
const origin = event.context.cloudflare?.request?.url || `https://${event.headers.get('host') || 'localhost'}`
18-
const url = new URL(fullPath, origin).href
19-
const res = await assets.fetch(url).catch(() => null) as Response | null
20-
if (res?.ok) {
21-
return Buffer.from(await res.arrayBuffer())
22-
}
23-
}
24-
25-
// Fallback: use event.fetch (Nitro localFetch) which routes through the h3 app
26-
// and can serve public assets from Nitro's built-in asset handler.
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-
}
34-
}
16+
const ab = await fetchLocalAsset(event, fullPath, { fetchTimeout: timeout })
17+
if (ab)
18+
return Buffer.from(ab)
3519

36-
if (!assets && !event.context._ogImageWarnedMissingAssets) {
20+
if (!getCloudflareAssets(event) && !event.context._ogImageWarnedMissingAssets) {
3721
event.context._ogImageWarnedMissingAssets = true
3822
console.warn(
3923
`[Nuxt OG Image] No ASSETS binding found on Cloudflare Workers. Font loading will fail. `

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { getRequestURL } from 'h3'
66
import { useRuntimeConfig } from 'nitropack/runtime'
77
import { join } from 'pathe'
88
import { withBase } from 'ufo'
9+
import { getFetchTimeout } from '../../../util/fetchTimeout'
10+
import { useOgImageRuntimeConfig } from '../../../utils'
911

1012
let fontUrlMapping: Record<string, string> | undefined
1113

@@ -19,6 +21,7 @@ async function loadFontUrlMapping(): Promise<Record<string, string>> {
1921

2022
export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer> {
2123
const path = font.src || font.localPath
24+
const timeout = getFetchTimeout(useOgImageRuntimeConfig())
2225

2326
// Static bundled fonts — read directly from absolute path
2427
if (font.absolutePath) {
@@ -48,7 +51,7 @@ export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer>
4851

4952
const mapping = await loadFontUrlMapping()
5053
if (mapping[filename]) {
51-
const res = await fetch(mapping[filename]).catch(() => null)
54+
const res = await fetch(mapping[filename], { signal: AbortSignal.timeout(timeout) }).catch(() => null)
5255
if (res?.ok)
5356
return Buffer.from(await res.arrayBuffer())
5457
}
@@ -78,7 +81,7 @@ export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer>
7881
const filename = path.slice('/_fonts/'.length)
7982
const mapping = await loadFontUrlMapping()
8083
if (mapping[filename]) {
81-
const res = await fetch(mapping[filename]).catch(() => null)
84+
const res = await fetch(mapping[filename], { signal: AbortSignal.timeout(timeout) }).catch(() => null)
8285
if (res?.ok)
8386
return Buffer.from(await res.arrayBuffer())
8487
}
@@ -99,7 +102,7 @@ export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer>
99102
const reqUrl = getRequestURL(event)
100103
const origin = `${reqUrl.protocol}//${reqUrl.host}`
101104
const url = new URL(withBase(path, app.baseURL), origin).href
102-
const res = await fetch(url).catch(() => null)
105+
const res = await fetch(url, { signal: AbortSignal.timeout(timeout) }).catch(() => null)
103106
if (res?.ok) {
104107
return Buffer.from(await res.arrayBuffer())
105108
}
@@ -108,6 +111,7 @@ export async function resolve(event: H3Event, font: FontConfig): Promise<Buffer>
108111
// @ts-expect-error excessive stack depth from Nuxt typed routes
109112
const arrayBuffer = await event.$fetch(fullPath, {
110113
responseType: 'arrayBuffer',
114+
timeout,
111115
}) as ArrayBuffer
112116
return Buffer.from(arrayBuffer)
113117
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@ import type { FontConfig } from '../../../../types'
33
import { getNitroOrigin } from '#site-config/server/composables'
44
import { useRuntimeConfig } from 'nitropack/runtime'
55
import { withBase } from 'ufo'
6+
import { getFetchTimeout } from '../../../util/fetchTimeout'
7+
import { useOgImageRuntimeConfig } from '../../../utils'
68

79
export async function resolve(event: H3Event, font: FontConfig) {
810
const path = font.src || font.localPath
911
const { app } = useRuntimeConfig()
1012
const fullPath = withBase(path, app.baseURL)
1113
const origin = getNitroOrigin(event)
12-
const res = await fetch(new URL(fullPath, origin).href).catch(() => null)
14+
const timeout = getFetchTimeout(useOgImageRuntimeConfig())
15+
const res = await fetch(new URL(fullPath, origin).href, { signal: AbortSignal.timeout(timeout) }).catch(() => null)
1316
if (res?.ok) {
1417
return Buffer.from(await res.arrayBuffer())
1518
}
1619
// Fallback to Nitro's internal handler when origin is unreachable
1720
// (behind a proxy, serverless, or server not fully started)
1821
const arrayBuffer = await event.$fetch(fullPath, {
1922
responseType: 'arrayBuffer',
23+
timeout,
2024
}) as ArrayBuffer
2125
return Buffer.from(arrayBuffer)
2226
}

src/runtime/server/og-image/browser/screenshot.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getNitroOrigin } from '#site-config/server/composables'
55
import { withQuery } from 'ufo'
66
import { toValue } from 'vue'
77
import { buildOgImageUrl } from '../../../shared'
8+
import { getFetchTimeout } from '../../util/fetchTimeout'
89
import { logger } from '../../util/logger'
910
import { useOgImageRuntimeConfig } from '../../utils'
1011

@@ -79,8 +80,9 @@ async function takeScreenshot(page: Page, selector: string | undefined, options:
7980
return await page.screenshot(puppeteerOptions)
8081
}
8182

82-
export async function createScreenshot({ basePath, e, options, extension }: OgImageRenderEventContext, browser: Browser): Promise<Buffer> {
83-
const { colorPreference, defaults, security } = useOgImageRuntimeConfig()
83+
export async function createScreenshot({ basePath, e, options, extension, timings }: OgImageRenderEventContext, browser: Browser): Promise<Buffer> {
84+
const runtimeConfig = useOgImageRuntimeConfig()
85+
const { colorPreference, defaults, security } = runtimeConfig
8486
// For browser renderer, we need to load the HTML template with options encoded in URL
8587
const path = options.component === 'PageScreenshot' ? basePath : buildOgImageUrl(options, 'html', false, defaults, security?.secret || undefined).url
8688

@@ -110,7 +112,8 @@ export async function createScreenshot({ basePath, e, options, extension }: OgIm
110112
}
111113
if (import.meta.prerender && !options.html) {
112114
// we need to do a nitro fetch for the HTML instead of rendering with browser
113-
options.html = await e.$fetch(path).catch(() => undefined) as string
115+
options.html = await timings.measure('html-fetch', () =>
116+
e.$fetch(path, { timeout: getFetchTimeout(runtimeConfig) }).catch(() => undefined)) as string
114117
}
115118

116119
await setViewport(
@@ -159,7 +162,7 @@ export async function createScreenshot({ basePath, e, options, extension }: OgIm
159162
}, _options.mask)
160163
}
161164

162-
return await takeScreenshot(page, _options.selector, screenshotOptions)
165+
return await timings.measure('render-browser', () => takeScreenshot(page, _options.selector, screenshotOptions))
163166
}
164167
finally {
165168
await page.close()

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProp
2121
import { autoEjectCommunityTemplate } from '../util/auto-eject'
2222
import { createNitroRouteRuleMatcher } from '../util/kit'
2323
import { normaliseOptions } from '../util/options'
24+
import { createTimings, TIMING_CTX_KEY } from '../util/timings'
2425
import { useOgImageRuntimeConfig } from '../utils'
2526
import { getBrowserRenderer, getSatoriRenderer, getTakumiRenderer } from './instances'
2627

@@ -257,6 +258,8 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
257258
statusMessage: `[Nuxt OG Image] Renderer "${rendererType}" is not available. Component "${normalised.component?.pascalName}" requires the ${rendererType} renderer but it's not bundled for this preset.`,
258259
})
259260
}
261+
const timings = (e.context[TIMING_CTX_KEY] as ReturnType<typeof createTimings>) || createTimings()
262+
e.context[TIMING_CTX_KEY] = timings
260263
const ctx: OgImageRenderEventContext = {
261264
e,
262265
key,
@@ -267,6 +270,7 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
267270
extension,
268271
basePath,
269272
options: normalised.options,
273+
timings,
270274
_nitro: useNitroApp(),
271275
}
272276
// call the nitro hook

0 commit comments

Comments
 (0)