Repository navigation
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
fe5c802 to
c0c254c
Compare
| ## Worker size | ||
|
|
||
| On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. Cloudflare limits a Worker to 3 MB compressed on Workers Free and 10 MB on Workers Paid (see [Worker size](https://developers.cloudflare.com/workers/platform/limits/#worker-size)), so on Workers Free the rest of your app has about 1.1 MB. Apps that do not import `void/og` do not include the renderer. |
There was a problem hiding this comment.
Cloudflare changed these limits on September 4, 2026: the 3 MB (Free) and 10 MB (Paid) check on the compressed bundle was removed, and Workers Free and Workers Paid now both allow 64 MiB, measured uncompressed (changelog, Worker size). The limits page now says: “There is no compressed size limit. Only the uncompressed bundle size counts.”
So void/og adds about 4 MiB, which fits on either plan with plenty of room.
There was a problem hiding this comment.
Thanks for the tip. I forgot about that and am glad they're much higher now. I'm glad the size concern is gone. Will update it!
There was a problem hiding this comment.
@ogawa0071 Thanks for the PR! I've pushed some follow-up commits on top of yours. Summary below, please have a look and lemme know if there's anything amiss.
At the bottom there are notes for reviewers (cc @cpojer):
Changes since the original submission
API
ImageResponseOptionsis now Void's own list, mapped to Takumi's options one by one:width,height,debug,fonts,fallbackFonts,emoji,lang,images(allowUrl,maxBytes,timeout,throwOnError), plusstatus,statusTextandheaders. Takumi's other options (format,devicePixelRatio,css,renderer,module,fontFamilies, …) are no longer accepted, so a Takumi upgrade can't change Void's API, and backend options can't bypass the per-image font handling.- A
fontsentry is a font file's URL or{ name, data, weight?, style?, lang? }.weightaccepts any value from 1 to 1000; leaving it out lets a variable font follow each element'sfontWeight(e.g.550). - New
fallbackFonts: falseturns off the Google Fonts downloads, for apps that pass their own fonts. Cache-Controlis alwayspublic, max-age=0, must-revalidate. The dev-onlyno-cache, no-storewas dropped: withmax-age=0and no validator, clients refetch anyway.
Fallback fonts
- Fonts are requested from Google Fonts with
text=and only the characters in the image, so Google returns one small file. On a deployed Worker, a 45-character Japanese title went from 21 requests / 480 KB to 2 requests / 26 KB. Text too long for one request falls back to the per-range files. - An explicit
langnow chooses the Chinese, Japanese or Korean style before kana or Hangul in the text, so a Traditional Chinese title with a katakana word stays Chinese. ・andーare drawn with the Japanese family rather than a separate request.- Han text next to Hangul is drawn: both scripts went to Noto Sans KR, and the Hangul request replaced the Han one, so mixed Chinese/Japanese/Korean text drew boxes.
- Variable families are requested with their full weight range, so
fontWeight: 700draws real bold rather than a thickened 400. - When one fallback file fails to download, only that file is dropped; the others still render.
- Font handling moved into
packages/void/src/runtime/og-fonts.ts.
Dependencies and build
- Upgraded to
takumi-js~2.20.0, which fixes 2.19.0's fallback regression ("Łódź" next to Japanese drew as boxes). 2.20.0 renders pixel-identical to 2.18.2 on both backends. - The Node.js build check finds void's
package.jsonwithout assuming the module's depth.
Docs
- The guide describes
void/ogon its own, with no comparisons to or migration from other libraries. - New "Control font downloads" section: memory growth on long-running Node.js, Bun and Deno servers, subrequests and first-render latency on Workers, and
fallbackFonts: falsewith the app's own fonts. - The Fonts section says your fonts come before Geist and names Noto as the fallback. The font example uses a Google Fonts URL.
- The Worker size section states the renderer's cost (about 4.3 MB, 1.9 MB compressed).
- Shorter
void/ognotes inpackages/void/AGENTS.md.
Tests: 34 unit tests and 6 integration tests, covering the cases above.
Live demo with Latin, Japanese, Chinese, Korean, per-lang Han and weight cards: https://void-og-test.yangshun.workers.dev
Reviewer notes
- CPU time on Workers Free. A simple card takes ~10 ms of CPU, richer ones 30–80 ms, and the first render in a new isolate >100 ms, against Free's 10 ms per request. The guide tells Free users to cache image routes with
routing.revalidate. These numbers are from Miniflare; CPU time on a deployed Worker hasn't been measured. - Downloads while rendering. By default, text Geist can't draw fetches a font from Google Fonts, and emoji come from jsDelivr. Each download counts as a subrequest and slows the first render; a failed font draws boxes rather than failing the image.
fallbackFonts: falseopts out. Google'stext=parameter has no documented length limit; Void falls back to the per-range files above 6,000 encoded characters, below where it was measured to stop working. - Memory on Node.js, Bun and Deno. Each new title keeps ~37 KB of font data until restart, unbounded. Takumi's
render()uses a default renderer withoutfontCacheMaxBytes. A follow-up could create Void's own renderer with a cap. Documented for now. takumi-jsinstall on Node.js targets. Apps must listtakumi-jsindependencies, because the native addon can't be bundled and the production install fetches the right binary for the server. Rendering with the Wasm build on Node.js instead was measured at 2.5–3× slower with ~70 MB more memory. Raising Void's~2.20.0range later will ask Node.js apps to update theirs (the build error names the command). I couldn't find a better way around this. I think this is fine for now and we can revisit this.- Remote images. Markup can fetch remote images. Apps that put request values in image URLs should set
images.allowUrlandmaxBytes. Worth a security look. - Known Takumi behaviors, documented or noted rather than worked around:
- One Chinese style applies per image, chosen by
lang. - Geist is built into Takumi and stays as the last fallback.
- An element whose
font-familynames a font the image didn't pass can draw with it if an earlier image on the same server registered it. Plain text withoutfont-familyisn't affected.
- One Chinese style applies per image, chosen by
- API narrowed from the original. The original passed Takumi's options through, including other output formats (JPEG, WebP, ICO). Those are gone. Happy to discuss if any were important.
|
@kossnocorp could you review this PR and merge it when it's ready? |
|
@cpojer on it |
d25c3db to
785fbcc
Compare
|
@yangshun @ogawa0071 heads up, I updated the branch via GitHub, and it force-pushed the branch 🫠 Forgot it does that. Be careful if want to pull. |
|
@kossnocorp No problem, I won't be pushing anything for now. You can make any changes you want. |
Add `void/og`, a thin layer over Takumi (`takumi-js`) that renders JSX or an HTML string to an image `Response` with the same arguments as `next/og`. On workerd, Takumi's Wasm loads through the Cloudflare plugin's compiled-module imports in dev and builds; Node.js and Bun use Takumi's native renderer. The wrapper adds what `next/og` callers expect: a 1200x630 default size, the `cache-control` header (no-store during development), `debug`, and Noto fallback fonts from Google Fonts for scripts the built-in Geist cannot draw. Latin text stays on Geist, and a failed font download renders without that font instead of failing the image. On the Node.js, Bun and Deno targets the build fails with a clear message when the app cannot resolve `takumi-js` itself, instead of bundling the native addon so every render fails at runtime. Apps that do not import `void/og` bundle nothing extra. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Miniflare types its own Response without Cloudflare's cf property, which failed the package typecheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Google Fonts puts ASCII into the CJK subsets that hold the most common characters, such as 日, 中 and 하. void/og dropped every subset that covered "A" to keep Latin text on the default font, so those characters rendered as missing glyphs. Skip only each family's Latin subset, and take Latin-1 out of the others. The built-in font also lacks Latin Extended-A and -B, so Polish, Czech, Turkish and similar text now loads Noto Sans as well. Fold the deprecated `stylesheets` option into `css` when the element brings its own styles, since takumi rejects both together. On the Node.js, Bun and Deno targets, the build now also fails when the app's takumi-js is outside the range void declares, and the error names the version to install. The docs give realistic CPU times, cover Deno, and say JSX works in React apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- Comment where each behavior comes from in @vercel/og 1.0.3, Satori 0.33.5 and Next.js 16.4.2, and where void/og differs. - Always pass Takumi a font list. Without one it used every font registered in the process, so an earlier image's fallback fonts changed the glyphs of later Latin-only images. - Check the takumi-js version on Node.js targets only for void's own imports, so an app can use another takumi-js version itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- Load void under Bun again: read void's package.json by path, not `findPackageJSON`, which Bun's `node:module` lacks. - Check the app's takumi-js for void's own imports only, following symlinks, and warn in the dev server instead of failing the build. - Draw Han text as Japanese like next/og unless kana, Hangul or `lang` say otherwise; add Traditional Chinese, math and symbol fallbacks. - Register fallback fonts under private names, so an app's own copy of the family does not change later images. - Keep only the fallback subsets the text uses, extract emoji before choosing fonts, and fetch images once when the render runs twice. - Keep the default size for `width: undefined`, accept Satori's font `lang`, fold `css` with `stylesheets`, and leave render errors to the runtime's report of the failed body. - Docs: Workers Free CPU numbers, WOFF2 only, Next.js 16 cache header. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- Integration: the Node target build fails without takumi-js and prints the install command; a Cloudflare app without React renders HTML. - Unit: emoji render as images without a font, a font's `lang` is dropped before takumi sees it, and no case reaches the network. The Google Fonts stubs answer 404, which takumi does not retry. - Guide: `from-font` and `ico` in the options table, the root-only `lang` attribute, the Props type in the head() example, and that passing `fonts` does not stop the Noto download. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- A Google Fonts face without a unicode-range, such as Noto Sans Math, covered every character and drew the Latin text of any image with a math symbol. Treat it as a whole font above Latin-1. - Detect fallback scripts only among characters the built-in Geist lacks, so `C++`, `©` or typographic quotes make no request, and µ gets a font. Accented Latin and Cyrillic come from Geist's own Google Fonts subsets; `Noto Sans Symbols` and `Noto Sans` cover number forms, currency, Greek and combining marks, as @vercel/og's `unknown` does. - Request each family's CSS separately so takumi's cache serves it, and forward `images.fetch` and `images.timeout` to the font downloads. - Key a font given as bytes by its content: Takumi keeps every registered font, so one read per request would grow without bound. - Keep emoji images for the life of the isolate, as @vercel/og does. - Send `public, max-age=0, must-revalidate` outside development, as Next.js 16 does: a failed render is still a complete response, which a year-long cache would keep. Log the failure again. - Drop Takumi's deprecated `stylesheets` from the public options, and read package manifests through installed-packages.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
- The retry without a failed fallback font fetched remote images again: share one image cache between both attempts. - Hash a font buffer once per buffer, not per request, so a font kept at module scope costs no CPU. - Count the combining marks and variation selectors the built-in Geist draws, so NFD text asks for no font. - Guide: WOFF loads on Node.js and Bun. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Main added .tsx and .jsx route files (voidzero-dev#561), so the guide no longer needs a separate module for the image's JSX: its example is one `routes/og/[slug].tsx` route, and the next/og migration note moves `app/api/og/route.tsx` to a route outside /api so it can be cached. The integration test renders its JSX card from a .tsx route in dev and in the built Worker. The guide says "local development" rather than naming a dev command, as main's docs now do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
void declared takumi-js ^2.16.2, so a fresh install already resolved 2.19.0, which the repository never tested. 2.19.0 loads only the first font whose unicode-range covers a character and draws the whole word as boxes when that font lacks the glyph: with Japanese in the image, Noto Sans JP's latin-ext subset claims Ł and "Łódź" renders as four boxes, and Noto Sans Symbols claims ❤ without having it. 2.16.2, 2.17.1 and 2.18.2 render both correctly. Test and ship 2.18.2, the newest release that does, and keep fresh installs on it with ~2.18.2. The Node.js build check reads ~ ranges and rejects 2.19.0, and the guide's install command names the range. 2.18.1 added images.throwOnError: the guide now says an emoji the chosen style lacks, or an image that fails to download, fails the image unless it is false, and a unit test covers both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
The guard tests now reject a patch below the minimum and a prerelease inside the ~2.18 window, and accept a later patch. The guide says what `throwOnError: false` does, and the maintainer note records that reordering the fallback families or dropping unicode-range does not avoid takumi-js 2.19.0's bug, and that a fix must be checked in a fresh process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
Measured on V8 with takumi-js 2.18.2's Wasm build, a simple card takes 6–17 ms of CPU rather than under 10 ms, a richer one 31–81 ms, and the first render 120–150 ms. "Under 10 ms" suggested a simple card fits the Workers Free limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016XQ1t7Wv2KEzUhSApX7CWT
The void/og Node.js check read `../package.json` relative to the plugin module, which only holds while the bundler emits it one directory below the package root. Walk up to the nearest package.json named `void`, and do it lazily so a lookup failure cannot break loading the plugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
void/og adds Takumi's Wasm renderer, about 1.7 MB compressed, to the Worker. Say how that fits Workers Free's 3 MB compressed limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kana Any kana in an image chose Noto Sans JP before `lang` was read, so a Traditional Chinese title with a katakana loanword was drawn in the Japanese style. An explicit `lang` now decides first; kana and Hangul decide only without one. Google's Noto Sans SC, TC and KR include kana, so the kana still draw. The guide notes that one style covers all Han text in an image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When any fallback font file failed, the image was rendered again with no fallback fonts at all, so a missing symbol font also dropped the Japanese one. Each image now records the files that fail, warns once per file, and retries without only those. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fallback fonts were requested from Google Fonts at weight 400 only. For variable families (Noto Sans JP, KR, SC, TC, the Indic, Thai, Arabic and Hebrew families, Noto Sans, Symbols and Geist) Takumi then pinned the face to 400, so bold Chinese, Japanese or Korean text was only thickened and 700 looked the same as 900. Request those families as 100..900, which Google serves as a weight range that Takumi leaves on the font's axis. Noto Sans Math and Noto Sans Symbols 2 have no weight axis and reject a range, so they stay at 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the comparisons with other libraries from the Open Graph guide and the API reference, and the migration section. Keep its note that a font entry's `lang` does not choose the font, under Fonts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.20.0 fixes the 2.19.0 font fallback regression: Japanese next to "Łódź", symbols, mixed scripts, bold Japanese and twemoji render pixel-identical to 2.18.2 with both the native and Wasm backends, and the built-in Geist draws the same code points. The Wasm grows from 1.7 MB to 1.9 MB compressed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s through
ImageResponseOptions now lists width, height, debug, fonts, emoji, lang,
images (allowUrl, maxBytes, timeout, throwOnError) and the ResponseInit
fields, and og.ts maps each one to Takumi's render options. A Takumi
upgrade no longer changes the API, and backend options such as renderer,
module and fontFamilies no longer bypass the per-image font handling.
A fonts entry is a font file's URL or { name, data, weight, style, lang }.
format, devicePixelRatio, css, emoji: 'from-font' and Takumi's other
options are no longer accepted, and options outside the list do not reach
Takumi at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A font's weight fixes the weight it draws, so a variable font set to 550 or a static Book (350) face needs a weight between the 100 steps. Leaving it out keeps a variable font free for each element's fontWeight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unicode files these marks under the Common script, so they matched no script's family and fell through to Noto Sans, which cost almost every Japanese image an extra Google Fonts request. Match by script extension. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
og.ts keeps the response, its options and the render; og-fonts.ts holds the font type, keying of the app's fonts and the Google Fonts fallbacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a `text` query, Google answers with one file of just those characters and the whole weight axis. A 45-character Japanese title now takes 2 requests and one 26 KB file instead of 21 requests and 480 KB. Text too long for one query still asks for the range files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each image with new text downloads a small font, and a server keeps it until it restarts. The guide now says so, and shows how to pass the fonts an app needs with fallbackFonts: false to download none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With max-age=0 and no validator, clients refetch the image on each use, so the dev server needs no `no-cache, no-store` of its own. This drops the runtime check for development, which read a flag that is also true in builds made with NODE_ENV=development. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep what maintainers act on: the declared options, the Node.js, Bun and Deno install check, and the Takumi upgrade steps. The font and Wasm details already live in the source comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cloudflare now limits a Worker to 64 MiB uncompressed on every plan and has no compressed limit, so the guide no longer counts down from 3 MB. It keeps what the renderer costs: 4.3 MB more to upload and to start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text with Hangul draws Han characters with Noto Sans KR, so both scripts asked for the same family, and the Hangul request replaced the Han one. "日本語・中文・한국어" drew its Han characters, and the numbers beside them, as boxes. Ask the family for both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
785fbcc to
1876160
Compare
kossnocorp
left a comment
There was a problem hiding this comment.
Only critical issue is accepting HTML string.
I would still rename the module to void/image, but I'm not sure how to handle default image sizes. I would remove them in this PR and add OgImageResponse (imported from void/og) with presets like: "standard" | "x-large" | "x-small" | "linkedin".
Using xxHash would be a bit faster that SHA but I don't think it matters.
| } | ||
| return new ImageResponse( | ||
| `<div style="display: flex; width: 100%; height: 100%; padding: 80px; font-size: 72px"> | ||
| ${escapeHtml(post.title)} |
There was a problem hiding this comment.
I think we should be very careful recommending this approach. It is way too easy to forget escaping and introduce a vulnerability.
I think it is better to disallow passing strings and enforce secure code practices via literal template that would escape by default:
export const GET = defineHandler(async (c) => {
const post = await getPost(c.req.param('slug'));
if (!post) {
return c.notFound();
}
return new ImageResponse(
html`<div style="display: flex; width: 100%; height: 100%; padding: 80px; font-size: 72px">
${post.title}
</div>`,
);
});The good news is that we already have it in Hono: https://hono.dev/docs/helpers/html
|
|
||
| Text uses Geist unless you pass `fonts`. Your fonts draw every character they have, and Geist draws only the characters they lack. | ||
|
|
||
| When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads a font that has them from Google Fonts while rendering: the matching [Noto](https://fonts.google.com/noto) font, such as Noto Sans JP for Japanese, or Geist's own Cyrillic and accented letters. It asks for only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. |
There was a problem hiding this comment.
I think we better stick to VoidZero's Inter rather than Geist.
|
|
||
| Text uses Geist unless you pass `fonts`. Your fonts draw every character they have, and Geist draws only the characters they lack. | ||
|
|
||
| When the text includes characters that Geist cannot draw, such as Japanese, Chinese, Korean, Thai, Arabic, Hebrew, Cyrillic, Greek, accented letters like Ł and ş, or symbols like → and ★, Void downloads a font that has them from Google Fonts while rendering: the matching [Noto](https://fonts.google.com/noto) font, such as Noto Sans JP for Japanese, or Geist's own Cyrillic and accented letters. It asks for only the characters the image uses, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes. |
There was a problem hiding this comment.
I think we better stick to the default VoidZero's Inter rather than Geist. It also has support for Cyrillic and Greek scripts: https://rsms.me/inter/#languages
There was a problem hiding this comment.
I prefer to use Inter as well, but didn't want to bundle an additional font. Takumi bundles Geist and there's no way to remove that. Considering Inter and Geist are similar enough, I chose to just stick with Geist.
| let hash = fontHashes.get(data); | ||
| if (!hash) { | ||
| hash = crypto.subtle | ||
| .digest('SHA-256', data instanceof ArrayBuffer ? data : new Uint8Array(data)) |
There was a problem hiding this comment.
Using xxHash would be faster than SHA. Not critical but I would use https://www.npmjs.com/package/@node-rs/xxhash
@Brooooooklyn WDYT?
| responseHeaders.set('content-type', 'image/png'); | ||
| } | ||
| if (!responseHeaders.has('cache-control')) { | ||
| responseHeaders.set('cache-control', 'public, max-age=0, must-revalidate'); |
There was a problem hiding this comment.
Curious what is the reasoning? I feel like it's safe to use the standard cache time that is applied by X/Facebook and others. Shouldn't it be optimized for performance out of the box, or there are other considerations?
There was a problem hiding this comment.
Yes. For many years Next.js was over-aggressive about caching and the community found it confusing, in Next 15 they reverted to no cache by default.
With the current way, there doesn't seem to be an easy way to invalidate the cached responses, and I wanted users to explicitly set a cache duration that suits the nature of their app / frequency of updates.
On hindsight maybe I was being too conservative and a standard/longer default would be better.
|
...and I would definitely use Inter instead of Geist. |
|
I haven't spent time looking at the API yet, but I see two issues in the implementation:
|
Closes #442
Summary
Adds
void/og: anImageResponsethat renders JSX or an HTML string to a PNG from a route handler, for Open Graph and social preview images, on Cloudflare Workers and on the Node.js, Bun and Deno targets.Options
ImageResponseOptionsis Void's own list, mapped to Takumi's render options one by one, so a Takumi upgrade does not change the API:width,height,debug,fonts,emoji(twemoji,blobmoji,noto,openmoji,fluent,fluentFlat),status,statusText,headers.lang: chooses the style of Chinese characters, as the root element'slangattribute does.images:allowUrl,maxBytes,timeoutandthrowOnErrorfor the images and emoji in the markup.allowUrlis how an app keeps request values in image URLs from reaching internal addresses on the Node.js target.fontsentry is{ name, data, weight?, style?, lang? }with the font's bytes, or a font file's URL.weightis any number from 1 to 1000; leaving it out lets a variable font follow each element'sfontWeight.fallbackFonts: falseturns off the Google Fonts downloads described below, for apps that pass their own fonts.Takumi's other options (
format,devicePixelRatio,css,renderer,moduleand so on) are not accepted.Why Takumi
Takumi (
takumi-js) ships a Wasm build with aworkerdexport condition, which the Cloudflare Vite plugin loads as a compiled module in dev and builds, plus a native addon for Node.js and Bun. It takes JSX or HTML and draws straight to pixels, sovoid/ogstays a thin wrapper.Warm render times for 1200 × 630 cards in workerd (Miniflare, median):
The first request in a new isolate takes about 110 ms. With the native addon on Node.js, the same cards take 3–10 ms.
What the wrapper adds
Content-Type, anddebug.Cache-Controlispublic, max-age=0, must-revalidate. The guide shows how to cache image routes withrouting.revalidate.text=and only the characters the image uses, so Google returns one small file. On a deployed Worker, a 45-character Japanese title takes 2 requests and a 26 KB file, against 21 requests and 480 KB for the per-range files. Text too long for one request (about 650 CJK characters) falls back to the per-range files.fallbackFonts: falsewith the app's own fonts avoids it.langnames the language or the text has Hangul. An explicitlangwins over kana, so a Traditional Chinese title with a katakana word stays Chinese.fontWeight: 700draws real bold rather than a thickened regular.packages/void/src/runtime/og-fonts.ts.Node.js, Bun and Deno
Takumi loads its renderer from files next to its own code, which a bundle cannot carry. On these targets the build fails with the install command when the app has no supported
takumi-jsin its own dependencies; the dev server only warns.The app installs
takumi-jsbecause the production install runs on the server, so it fetches the native binary for that OS and CPU. Two alternatives were considered and left out:takumi-jsand the build machine's binary intodist/ssradds about 12 MB and breaks when the build runs on another platform than the server, such as macOS to a Linux container.The build accepts any
takumi-jsin Void's~2.20.0range, so raising that range later asks Node.js apps to update theirs.Workers Free
Apps that don't import
void/ogbundle nothing extra (covered by a test). Takumi's Wasm adds about 4.3 MB (1.9 MB gzipped) to the Worker. A simple card takes around 10 ms of CPU, richer ones 30–80 ms, and the first render in a new isolate more than 100 ms, so the guide tells Workers Free apps to cache image routes with revalidation.takumi-js version
takumi-jsis on~2.20.0. 2.19.0 loads only the first font whoseunicode-rangecovers a character and draws the whole word as .notdef boxes when that font lacks the glyph: with Japanese in the image, "Łódź" renders as boxes. 2.20.0 fixes this: Japanese next to Polish, symbols, mixed scripts, bold Japanese and twemoji render pixel-identical to 2.18.2 with both the native and Wasm backends, and the built-in Geist draws the same code points. The build rejects 2.19.x.packages/void/AGENTS.mdnotes how to check a version before raising the range.Docs
docs/guide/og-images.md, with options, fonts, controlling font downloads, caching, Worker size, and the Node.js, Bun and Deno setup.void/ogin the API reference, a link from thehead()guide, a note in the Node.js, Bun and Deno integration guide, and a task row in thevoidskill.Tests
packages/void/test/unit/og.test.ts(34 tests): defaults and headers, JSX, font weights, options outsideImageResponseOptionsnot reaching Takumi, fallback-font selection andtext=requests (including Han text next to Hangul), the long-text fallback,fallbackFonts: false, weights and subsetting,lang, per-image font isolation, partial fallback download failures, emoji,images.throwOnError, and the Node.js build check.packages/void/test/integration/og-image.test.ts(6 tests): the dev server in workerd, the built Worker in Miniflare (oneCompiledWasmmodule), an app without React, an app withoutvoid/og(no Wasm in its bundle), and the Node.js target, which renders with the app'stakumi-jsand fails the build without it..github/scripts/packed-consumer.mjsrenders an image route from the packed tarballs in dev and preview, and on the Node.js target.🤖 Generated with Claude Code