Repository navigation
Conversation
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
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #442
Summary
Adds
void/og: anImageResponsethat takes the same arguments asnext/ogand 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.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 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, sovoid/ogstays a thin wrapper.What the wrapper adds
next/ogdefaults: a 1200 × 630 PNG,Content-Type, anddebug.Cache-Controlispublic, max-age=0, must-revalidate, as in Next.js 16, andno-cache, no-storeduring local development. The guide shows how to cache image routes withrouting.revalidate.@vercel/ogdoes. Han text is drawn as Japanese, as innext/og, unless Hangul orlangsays otherwise. Latin text stays on the built-in Geist, and a font that fails to download is left out instead of failing the image.packages/void/src/runtime/og.tscite 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-jsin its own dependencies; the dev server only warns.Workers Free
Apps that don't import
void/ogbundle 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-jsis pinned to~2.18.2. 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 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.mdnotes how to check a fix before raising the range.Docs
docs/guide/og-images.md, including "Coming fromnext/og" 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(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 (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.Ran
vp run build:all,vp run check,vp run build:docs, the tests above, and the packed-consumer smoke suite (npm and pnpm).