Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export async function createBrowser(_event?: H3Event): Promise<Browser | void> {
return playwrightCore.chromium.launch({
headless: true,
executablePath: chromePath,
timeout: 15_000,
})
}
8 changes: 6 additions & 2 deletions src/runtime/server/og-image/bindings/browser/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { H3Event } from 'h3'
import { withTimeout } from '../../../util/withTimeout'
import { useOgImageRuntimeConfig } from '../../../utils'

// Type definitions for Cloudflare puppeteer (user must install @cloudflare/puppeteer)
Expand Down Expand Up @@ -51,7 +52,10 @@ export async function createBrowser(event?: H3Event): Promise<Browser> {
)
}

browserPromise = (async () => {
// Bound the launch/connect path; pptr can hang waiting on the binding
// and would otherwise pin the request to the outer renderTimeout.
const launchTimeout = useOgImageRuntimeConfig().security?.renderTimeout ?? 15_000
browserPromise = withTimeout((async () => {
const pptr = await getPuppeteer()
// Reuse existing sessions when possible (Cloudflare best practice)
const sessions = await pptr.sessions(binding)
Expand All @@ -70,7 +74,7 @@ export async function createBrowser(event?: H3Event): Promise<Browser> {
})

return browser!
})()
})(), launchTimeout, 'cloudflare browser launch')

browser = await browserPromise
browserPromise = null
Expand Down
1 change: 1 addition & 0 deletions src/runtime/server/og-image/bindings/browser/on-demand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ export async function createBrowser(_event?: H3Event): Promise<Browser | void> {

return await playwrightCore.chromium.launch({
headless: true,
timeout: 15_000,
})
}
4 changes: 4 additions & 0 deletions src/runtime/server/og-image/bindings/browser/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import type { H3Event } from 'h3'
import type { Browser } from 'playwright-core'
import playwright from 'playwright'

// Playwright's launch can hang waiting for the browser process to start
// (slow disk, antivirus, missing deps). Pass `timeout` to its launcher so
// the launch itself rejects rather than blocking the request indefinitely.
export async function createBrowser(_event?: H3Event): Promise<Browser | void> {
return await playwright.chromium.launch({
headless: true,
timeout: 15_000,
})
}
12 changes: 10 additions & 2 deletions src/runtime/server/og-image/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { autoEjectCommunityTemplate } from '../util/auto-eject'
import { createNitroRouteRuleMatcher } from '../util/kit'
import { normaliseOptions } from '../util/options'
import { createTimings, TIMING_CTX_KEY } from '../util/timings'
import { withTimeout } from '../util/withTimeout'
import { useOgImageRuntimeConfig } from '../utils'
import { getBrowserRenderer, getSatoriRenderer, getTakumiRenderer } from './instances'

Expand Down Expand Up @@ -274,7 +275,14 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
// @ts-expect-error hookable v6
_nitro: useNitroApp(),
}
// call the nitro hook
await ctx._nitro.hooks.callHook('nuxt-og-image:context', ctx)
// call the nitro hook β€” bound by renderTimeout so a hung user hook can't
// pin the request indefinitely. The hook continues running in the
// background; we just stop awaiting it.
const hookTimeout = runtimeConfig.security?.renderTimeout ?? 15_000
await withTimeout(
ctx._nitro.hooks.callHook('nuxt-og-image:context', ctx),
hookTimeout,
'nuxt-og-image:context hook',
)
return ctx
}
8 changes: 5 additions & 3 deletions src/runtime/server/og-image/core/vnodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@ export async function createVNodes(ctx: OgImageRenderEventContext, options?: Cre
logger.warn('The `html` option is deprecated and will be removed in the next major version. Use a Vue component instead.')
}
if (!html) {
const island = await fetchIsland(ctx.e, ctx.options.component!, typeof ctx.options.props !== 'undefined' ? ctx.options.props as Record<string, any> : ctx.options)
const islandTimeout = ctx.runtimeConfig.security?.renderTimeout ?? 15_000
const island = await ctx.timings.measure('island-fetch', () => fetchIsland(ctx.e, ctx.options.component!, typeof ctx.options.props !== 'undefined' ? ctx.options.props as Record<string, any> : ctx.options, islandTimeout))
// this fixes any inline style props that need to be wrapped in single quotes, such as:
// background image, fonts, etc
island.html = htmlDecodeQuotes(island.html)
Expand All @@ -252,7 +253,8 @@ export async function createVNodes(ctx: OgImageRenderEventContext, options?: Cre
rootChild.props.style = { width: '100%', height: '100%', ...rootChild.props.style }
}
warnUnsupportedSvgElements(vnodeTree, ctx.options.component)
// run shared plugins
await Promise.all(walkTree(ctx, vnodeTree, [encoding, styleDirectives, imageSrc]))
// run shared plugins (encoding, styleDirectives, imageSrc); image-fetch
// attributes externally so vnode-walk reflects everything else.
await ctx.timings.measure('vnode-walk', () => Promise.all(walkTree(ctx, vnodeTree, [encoding, styleDirectives, imageSrc])))
return vnodeTree
}
10 changes: 8 additions & 2 deletions src/runtime/server/og-image/satori/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { OgImageRenderEventContext, Renderer, RuntimeFontConfig } from '../
import { defu } from 'defu'
import { tw4FontVars } from '#og-image-virtual/tw4-theme.mjs'
import compatibility from '#og-image/compatibility'
import { withTimeout } from '../../util/withTimeout'
import { useOgImageRuntimeConfig } from '../../utils'
import { buildSubsetFamilyChain, extractCodepoints, getDefaultFontFamily, loadAllFontsDebug, loadFontsForRenderer, resolveSubsetChain } from '../fonts'
import { getResvg, getSatori, getSharp } from './instances'
Expand Down Expand Up @@ -39,7 +40,7 @@ export async function createSvg(event: OgImageRenderEventContext): Promise<{ svg
const { satoriOptions: _satoriOptions } = useOgImageRuntimeConfig()
const { fontFamilyOverride, defaultFont } = getDefaultFontFamily(options)
const [satori, vnodes] = await Promise.all([
getSatori(),
timings.measure('satori-init', () => getSatori()),
createVNodes(event),
])
const codepoints = extractCodepoints(vnodes)
Expand All @@ -52,7 +53,12 @@ export async function createSvg(event: OgImageRenderEventContext): Promise<{ svg
fontDefs: options.fonts,
}))

await event._nitro.hooks.callHook('nuxt-og-image:satori:vnodes', vnodes, event)
const hookTimeout = event.runtimeConfig.security?.renderTimeout ?? 15_000
await withTimeout(
event._nitro.hooks.callHook('nuxt-og-image:satori:vnodes', vnodes, event),
hookTimeout,
'nuxt-og-image:satori:vnodes hook',
)
// Remap to satori's font format (requires `name` instead of `family`).
// Use WeakMap cache only for base fonts (stable reference from fontArrayCache).
// Custom font arrays are per-request so can't benefit from identity caching.
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/og-image/takumi/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const RE_GT = />/g

export async function createTakumiNodes(ctx: OgImageRenderEventContext): Promise<Node> {
const vnodeTree = await createVNodes(ctx)
return await vnodeToTakumiNode(vnodeTree, DEFAULT_FONT_SIZE)
return await ctx.timings.measure('takumi-nodes', () => vnodeToTakumiNode(vnodeTree, DEFAULT_FONT_SIZE))
}

// Extract numeric width/height from HTML attributes
Expand Down
110 changes: 88 additions & 22 deletions src/runtime/server/og-image/takumi/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { logger } from '../../../logger'
import { fetchLocalAsset } from '../../util/fetchLocalAsset'
import { getFetchTimeout } from '../../util/fetchTimeout'
import { fetchWithRedirectValidation, isBlockedUrl } from '../../util/ssrf'
import { withTimeout } from '../../util/withTimeout'
import { buildSubsetFamilyChain, extractCodepoints, getDefaultFontFamily, loadFontsForRenderer, resolveSubsetChain } from '../fonts'
import { getExtractResourceUrls, getTakumi } from './instances'
import { createTakumiNodes } from './nodes'
Expand Down Expand Up @@ -37,10 +38,63 @@ async function getTakumiState(event: OgImageRenderEventContext): Promise<TakumiS
return nitro._takumiState
}

function withTakumiLock<T>(state: TakumiState, fn: () => Promise<T>): Promise<T> {
const next = state.lock.then(fn, fn)
state.lock = next.catch(() => undefined)
return next
function withTakumiLock<T>(
state: TakumiState,
timeoutMs: number,
fn: () => Promise<T>,
onLockTimeout?: () => void,
): Promise<T> {
const guarded = async (): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
fn(),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`takumi render timed out after ${timeoutMs}ms (lock-held)`)),
timeoutMs,
)
}),
])
}
catch (err) {
// The hung fn() is still running and holds linear memory. Recreate the
// renderer so the next caller starts on a fresh isolate-local instance;
// the old renderer is unreferenced and GC'd once fn() finally settles.
// Without this reset the lock chain grows unbounded under hangs and
// burns CPU on every subsequent waiter.
try {
const Renderer = await getTakumi()
state.renderer = new Renderer()
state.loadedFontKeys.clear()
state.loadedFamilies.clear()
}
catch (resetErr) {
logger.warn(`failed to reset takumi renderer after lock timeout: ${(resetErr as Error)?.message || resetErr}`)
}
throw err
}
finally {
clearTimeout(timer)
}
}
// Schedule the work once; future callers chain off this same `work`.
const work = state.lock.then(guarded, guarded)
state.lock = work.catch(() => undefined)
// Lock-acquire timeout: if `work` (or the previous holder it queues behind)
// hangs past timeoutMs, release THIS caller early so the upstream request
// returns 408 instead of contributing to a snowballing queue. The work
// itself keeps running on the chain so its own inner timeout can recover.
let acquireTimer: ReturnType<typeof setTimeout> | undefined
return Promise.race([
work,
new Promise<never>((_, reject) => {
acquireTimer = setTimeout(() => {
onLockTimeout?.()
reject(new Error(`takumi lock acquire timed out after ${timeoutMs}ms`))
}, timeoutMs)
}),
]).finally(() => clearTimeout(acquireTimer))
}

interface FontEntry { family: string, src?: string, data?: BufferSource, weight?: number, style?: string, cacheKey?: string }
Expand Down Expand Up @@ -175,15 +229,20 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp
const codepoints = extractCodepoints(nodes)
const fonts = await timings.measure('font-load', () => loadFontsForRenderer(event, { supportedFormats: new Set(['ttf', 'woff2'] as const), preferStatic: true, component: options.component, fontFamilyOverride: fontFamilyOverride || defaultFont, codepoints }))

await event._nitro.hooks.callHook('nuxt-og-image:takumi:nodes' as any, nodes, event)
const hookTimeout = event.runtimeConfig.security?.renderTimeout ?? 15_000
await withTimeout(
event._nitro.hooks.callHook('nuxt-og-image:takumi:nodes' as any, nodes, event),
hookTimeout,
'nuxt-og-image:takumi:nodes hook',
)

const subsetChains = buildSubsetFamilyChain(fonts)

const state = await getTakumiState(event)
const state = await timings.measure('takumi-init', () => getTakumiState(event))

// Resource extraction + fetching can run outside the WASM lock β€” they don't
// touch the shared Renderer instance, so concurrent requests can overlap I/O.
const extractResourceUrls = await getExtractResourceUrls()
const extractResourceUrls = await timings.measure('takumi-extract-init', () => getExtractResourceUrls())
const resourceUrls = await extractResourceUrls(nodes)

const baseURL = event.runtimeConfig.app.baseURL
Expand Down Expand Up @@ -243,24 +302,31 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp

// WASM critical section: loadFont and render mutate the shared Renderer's
// linear memory. Serializing per isolate avoids cross-request corruption.
return await withTakumiLock(state, () => timings.measure('render-takumi', async () => {
await loadFontsIntoRenderer(state, fonts)
// lock-wait captures queue time behind prior renders; render-takumi only
// covers post-acquire work so the two are additive and attributable.
const lockTimeout = event.runtimeConfig.security?.renderTimeout ?? 15_000
const endLockWait = timings.start('lock-wait')
return await withTakumiLock(state, lockTimeout, () => {
endLockWait()
return timings.measure('render-takumi', async () => {
await loadFontsIntoRenderer(state, fonts)

const rootStyle = nodes.style ?? {}
if (fontFamilyOverride) {
const chain = subsetChains.get(fontFamilyOverride)
if (chain) {
rootStyle.fontFamily = chain.map(f => `"${f}"`).join(', ')
const rootStyle = nodes.style ?? {}
if (fontFamilyOverride) {
const chain = subsetChains.get(fontFamilyOverride)
if (chain) {
rootStyle.fontFamily = chain.map(f => `"${f}"`).join(', ')
}
else if (state.loadedFamilies.has(fontFamilyOverride)) {
rootStyle.fontFamily = fontFamilyOverride
}
}
else if (state.loadedFamilies.has(fontFamilyOverride)) {
rootStyle.fontFamily = fontFamilyOverride
}
}
nodes.style = rootStyle
rewriteFontFamilies(nodes, state.loadedFamilies, subsetChains)
nodes.style = rootStyle
rewriteFontFamilies(nodes, state.loadedFamilies, subsetChains)

return state.renderer.render(nodes, renderOptions)
}))
return state.renderer.render(nodes, renderOptions)
})
}, endLockWait)
}

const TakumiRenderer: Renderer = {
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/server/og-image/templates/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export async function html(ctx: OgImageRenderEventContext) {
statusMessage: `[Nuxt OG Image] Rendering an invalid component. Received options: ${JSON.stringify(options)}.`,
})
}
const island = await fetchIsland(ctx.e, ctx.options.component!, typeof ctx.options.props !== 'undefined' ? ctx.options.props as Record<string, any> : ctx.options)
const islandTimeout = ctx.runtimeConfig.security?.renderTimeout ?? 15_000
const island = await fetchIsland(ctx.e, ctx.options.component!, typeof ctx.options.props !== 'undefined' ? ctx.options.props as Record<string, any> : ctx.options, islandTimeout)
const head = createHead()
head.push(island.head)

Expand Down
3 changes: 3 additions & 0 deletions src/runtime/server/util/eventHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export async function imageEventHandler(e: H3Event) {
if (ctx instanceof H3Error)
return ctx
const timings = ctx.timings
// resolveContext creates the timings instance, so record after the fact
// using the wall-clock measured from imageEventHandler entry.
timings.record('resolve-context', performance.now() - reqStart)
try {
return await renderOgImage(e, ctx)
}
Expand Down
7 changes: 6 additions & 1 deletion src/runtime/server/util/kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ import { withoutBase, withoutTrailingSlash } from 'ufo'

export { withoutQuery } from 'nuxtseo-shared/utils'

export function fetchIsland(e: H3Event, component: string, props: Record<string, any>): Promise<NuxtIslandResponse> {
export function fetchIsland(e: H3Event, component: string, props: Record<string, any>, timeout?: number): Promise<NuxtIslandResponse> {
const hashId = hash([component, props]).replaceAll('_', '-')
// signal aborts the underlying fetch; `timeout` is the @nuxt/fetch-level
// guard (some adapters honor one but not the other).
const signal = timeout ? AbortSignal.timeout(timeout) : undefined
return e.$fetch<NuxtIslandResponse>(`/__nuxt_island/${component}_${hashId}.json`, {
params: {
props: JSON.stringify(props),
},
timeout,
signal,
})
}

Expand Down
17 changes: 17 additions & 0 deletions src/runtime/server/util/withTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Race `promise` against a deadline. The loser keeps running β€” JS has no
// generic cancellation β€” but the await-chain unblocks so upstream timeouts
// (renderTimeout, the takumi lock, etc.) don't accumulate behind it.
// The error message contains "timed out" so eventHandlers maps it to 408.
// Accepts sync values too (hookable's callHook returns `void | Promise<any>`).
export function withTimeout<T>(promise: Promise<T> | T, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
return Promise.race([
Promise.resolve(promise),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms,
)
}),
]).finally(() => clearTimeout(timer))
}
Loading