Skip to content

feat(void): add next/og-compatible ImageResponse in void/og - #688

Open
ogawa0071 wants to merge 14 commits into
voidzero-dev:mainfrom
ogawa0071:feat/og-image
Open

ogawa0071 wants to merge 14 commits into
voidzero-dev:mainfrom
ogawa0071:feat/og-image

Conversation

@ogawa0071

Copy link
Copy Markdown

Closes #442

Summary

Adds void/og: an ImageResponse that takes the same arguments as next/og and renders JSX or an HTML string to a PNG from a route handler, on Cloudflare Workers and on the Node.js, Bun and Deno targets.

// routes/og/[slug].tsx
import { defineHandler } from 'void';
import { ImageResponse } from 'void/og';

export const GET = defineHandler((c) =>
  new ImageResponse(
    <div style={{ display: 'flex', fontSize: 72 }}>Hello {c.req.param('slug')}</div>,
  ),
);

Why Takumi rather than Satori

The current Satori (0.62) fails in workerd: its HarfBuzz Emscripten loader reads self.location.href (TypeError: Cannot read properties of undefined (reading 'href') in Miniflare), so using it would mean rewriting Satori's inlined loader in Void's Vite plugin. Takumi (takumi-js) ships a Wasm build with a workerd export 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, so void/og stays a thin wrapper.

What the wrapper adds

  • next/og defaults: a 1200 × 630 PNG, Content-Type, and debug. Cache-Control is public, max-age=0, must-revalidate, as in Next.js 16, and no-cache, no-store during local development. The guide shows how to cache image routes with routing.revalidate.
  • Fallback fonts: Takumi's built-in Geist covers Latin, so for text it cannot draw (Japanese, Chinese, Korean, Thai, Arabic, Cyrillic, symbols and more) the wrapper loads the matching Noto family, or Geist's own Latin Extended and Cyrillic subsets, from Google Fonts, much as @vercel/og does. Han text is drawn as Japanese, as in next/og, unless Hangul or lang says otherwise. Latin text stays on the built-in Geist, and a font that fails to download is left out instead of failing the image.
  • Each image renders with only its own fonts (Takumi otherwise uses every font registered in the process), fonts passed as bytes register once, and emoji images are cached per isolate.
  • Comments in packages/void/src/runtime/og.ts cite the @vercel/og, Satori and Next.js code each behavior follows.

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-js in its own dependencies; the dev server only warns.

Workers Free

Apps that don't import void/og bundle nothing extra (covered by a test). Takumi's Wasm is about 4 MB (1.7 MB gzipped). Measured on V8 with the Wasm build, 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-js is pinned to ~2.18.2. 2.19.0 loads only the first font whose unicode-range covers a character and draws the whole word as .notdef boxes when that font lacks the glyph: with Japanese in the image, "Łódź" renders as four boxes, and Noto Sans Symbols claims ❤ without having it. 2.16.2, 2.17.1 and 2.18.2 render the same images correctly. packages/void/AGENTS.md notes how to check a fix before raising the range.

Docs

  • New guide: docs/guide/og-images.md, including "Coming from next/og" and the Node.js, Bun and Deno setup.
  • void/og in the API reference, a link from the head() guide, a note in the Node.js, Bun and Deno integration guide, and a task row in the void skill.

Tests

  • packages/void/test/unit/og.test.ts (25 tests): defaults and headers, formats, JSX, fallback-font selection and subsetting, per-image font isolation, 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 (one CompiledWasm module), an app without React, an app without void/og (no Wasm in its bundle), and the Node.js target, which renders with the app's takumi-js and fails the build without it.
  • .github/scripts/packed-consumer.mjs renders an image route from the packed tarballs in dev and preview, and on the Node.js target.

Ran vp run build:all, vp run check, vp run build:docs, the tests above, and the packed-consumer smoke suite (npm and pnpm).

ogawa0071 and others added 14 commits October 16, 2026 13:59
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
Bring in voidzero-dev/void main at 7091135. The lockfile is main's with
takumi-js added again at the tested 2.16.2.

Co-Authored-By: Claude Opus 5.5 <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
@ogawa0071
ogawa0071 requested a review from a team as a code owner November 3, 2026 14:44
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedtakumi-js@​2.18.21001009696100

View full report

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

next/og compatible function built on Satori

1 participant