diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs
index 4e36188d2..5210c8ec6 100644
--- a/.github/scripts/packed-consumer.mjs
+++ b/.github/scripts/packed-consumer.mjs
@@ -116,6 +116,22 @@ async function run(command, args, cwd, env = environment) {
}
}
+const packedImageRoute = `import { ImageResponse } from 'void/og';
+export const GET = () => new ImageResponse('
Packed
');
+`;
+
+/** Fetches the void/og route and checks for a 1200x630 PNG. */
+async function assertPackedImage(url, failure) {
+ const response = await fetch(url);
+ assert.equal(response.status, 200, failure());
+ assert.equal(response.headers.get('content-type'), 'image/png', failure());
+ // A render error arrives after the status, as a failed body.
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ assert.deepEqual([...bytes.subarray(1, 4)], [80, 78, 71], failure());
+ assert.deepEqual([view.getUint32(16), view.getUint32(20)], [1200, 630], failure());
+}
+
const packages = [];
for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) {
if (!entry.isDirectory()) {
@@ -143,7 +159,8 @@ writeFileSync(
environment.npm_config_registry = registry.origin;
environment.NPM_CONFIG_REGISTRY = registry.origin;
environment.PNPM_CONFIG_REGISTRY = registry.origin;
-const version = packages.find((pkg) => pkg.manifest.name === 'void').manifest.version;
+const voidManifest = packages.find((pkg) => pkg.manifest.name === 'void').manifest;
+const version = voidManifest.version;
const managers = process.env.VOID_PACKED_PM ? [process.env.VOID_PACKED_PM] : ['npm', 'pnpm'];
assert(managers.every((manager) => ['npm', 'pnpm'].includes(manager)));
const frameworks = process.env.VOID_PACKED_FRAMEWORK
@@ -329,6 +346,8 @@ try {
join(fixture, 'routes/api/packed-email.ts'),
`import { defineHandler } from 'void';\nimport { sendEmail } from 'void/email';\nexport const GET = defineHandler(async () => { return sendEmail({ from: 'packed@example.com', to: 'inbox@example.com', subject: 'Packed', text: 'ok' }); });\n`,
);
+ // void/og imports its renderer's Wasm, and React only when the app has it.
+ writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute);
mkdirSync(join(fixture, 'public'), { recursive: true });
writeFileSync(join(fixture, 'public/packed.txt'), label);
const vite = join(
@@ -421,6 +440,7 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
} else {
assert.equal(email.error?.code, 'BINDING_MISSING', emailBody);
}
+ await assertPackedImage(`${base}/api/packed-og`, task.failure);
if (full && framework === 'react') {
const markdown = await fetch(`${base}/markdown`);
assert.equal(markdown.status, 200, task.failure());
@@ -463,9 +483,11 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
if (framework === 'react') {
// The Node target bundles the installed void into its build, so a ValidationError from
// void/handler is the class that page actions check, as in dev.
+ // void/og renders with the app's own takumi-js on Node.js, as its guide says.
+ const takumi = `takumi-js@${voidManifest.dependencies['takumi-js']}`;
await run(
manager,
- manager === 'npm' ? ['install', '@hono/node-server'] : ['add', '@hono/node-server'],
+ [manager === 'npm' ? 'install' : 'add', '@hono/node-server', takumi],
fixture,
);
writeFileSync(
@@ -474,6 +496,8 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
);
// KV and email are Cloudflare bindings.
rmSync(join(fixture, 'routes'), { recursive: true });
+ mkdirSync(join(fixture, 'routes/api'), { recursive: true });
+ writeFileSync(join(fixture, 'routes/api/packed-og.ts'), packedImageRoute);
writeFileSync(
join(fixture, 'pages/form.tsx'),
'export default function Form() {\n return
Form
;\n}\n',
@@ -507,12 +531,13 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
const body = await response.text();
assert.equal(response.status, 200, `${body}\n${task.failure()}`);
assert(body.includes('Email already taken'), body);
+ await assertPackedImage(`${base}/api/packed-og`, task.failure);
} finally {
await task.stop();
}
}
console.log(
- `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`,
+ `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+ build/typecheck/test' : 'public Vite build/typecheck'}, assets, KV, email, image, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`,
);
} catch (error) {
failures.push({ fixture: label, error: String(error.stack ?? error) });
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 3139a0f97..09433c619 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -335,6 +335,7 @@ export default extendConfig(
},
{ text: 'Custom SSR', link: '/guide/ssr' },
{ text: 'SSG', link: '/guide/ssg' },
+ { text: 'Open Graph Images', link: '/guide/og-images' },
],
},
{
diff --git a/docs/guide/og-images.md b/docs/guide/og-images.md
new file mode 100644
index 000000000..9ec11b410
--- /dev/null
+++ b/docs/guide/og-images.md
@@ -0,0 +1,196 @@
+---
+outline: deep
+---
+
+# Open Graph Images
+
+Use `ImageResponse` from `void/og` to generate Open Graph and social preview images in a route handler. It draws JSX or HTML to a PNG with [Takumi](https://takumi.kane.tw/docs/), without a headless browser.
+
+## Create an image route
+
+Return an `ImageResponse` from a [`.tsx` route](./server-routing.md#jsx-handlers):
+
+```tsx
+// routes/og/[slug].tsx
+import { defineHandler } from 'void';
+import { ImageResponse } from 'void/og';
+import { getPost } from '../../src/posts';
+
+export const GET = defineHandler(async (c) => {
+ const post = await getPost(c.req.param('slug'));
+ if (!post) {
+ return c.notFound();
+ }
+ return new ImageResponse(
+
+
{post.title}
+
{post.author}
+
,
+ );
+});
+```
+
+`GET /og/hello-world` now returns a 1200 × 630 PNG.
+
+JSX works in React apps. In Vue, Svelte, and Solid apps, pass an HTML string instead, as shown in [Without React](#without-react). Elements also accept Tailwind classes in a `tw` prop, such as `
`.
+
+### Without React
+
+Pass an HTML string instead of JSX, and escape the values you insert:
+
+```ts
+// routes/og/[slug].ts
+import { defineHandler } from 'void';
+import { ImageResponse } from 'void/og';
+import { getPost } from '../../src/posts';
+
+const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => `${char.charCodeAt(0)};`);
+
+export const GET = defineHandler(async (c) => {
+ const post = await getPost(c.req.param('slug'));
+ if (!post) {
+ return c.notFound();
+ }
+ return new ImageResponse(
+ `
+ ${escapeHtml(post.title)}
+
`,
+ );
+});
+```
+
+## Options
+
+The second argument sets the image and the response:
+
+| Option | Default | Description |
+| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| `width`, `height` | `1200`, `630` | Image size in pixels. |
+| `fonts` | Geist | Fonts to draw text with. See [Fonts](#fonts). |
+| `fallbackFonts` | `true` | Set to `false` to download no fonts from Google Fonts. See [Control font downloads](#control-font-downloads). |
+| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, or `fluentFlat`. |
+| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. |
+| `debug` | `false` | Draws a border around every box. |
+| `images` | | Limits for remote images and emoji: `allowUrl`, `maxBytes`, `timeout`, and `throwOnError`. Set them when the markup includes request values. |
+| `status`, `statusText`, `headers` | | The response status and headers. |
+
+Responses have these headers unless you set them in `headers`:
+
+```http
+Content-Type: image/png
+Cache-Control: public, max-age=0, must-revalidate
+```
+
+Clients revalidate the image on each use; [cache it at the edge](#cache-generated-images) so they get the cached copy.
+
+Emoji and remote images are downloaded while the image renders. An emoji that the chosen style does not have, or an image that fails to download, fails the image unless you pass `images: { throwOnError: false }`, which leaves it out; `noto` has no flags.
+
+## Fonts
+
+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.
+
+Chinese characters are drawn in the Japanese style unless the `lang` option or the root element's `lang` attribute names the language, or the text has Korean around them. A `lang` wins over the text, so a Chinese title with a katakana word keeps the Chinese style:
+
+```ts
+return new ImageResponse(`
${title}
`);
+```
+
+One style applies to all Chinese characters in an image, so an image cannot mix Japanese and Chinese styles through `lang` on inner elements.
+
+To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data:
+
+```ts
+return new ImageResponse(postCard(post), {
+ fonts: [
+ 'https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hiA.woff2',
+ { name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
+ ],
+});
+```
+
+A `weight` from 1 to 1000 makes the font draw only that weight. Leave it out for a variable font, so each element's `fontWeight` sets the weight, including values between the usual steps such as `550`.
+
+Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too. A font entry's `lang` is accepted but does not choose the font for a language; use the `lang` option or attribute for Chinese characters instead.
+
+### Control font downloads
+
+Each server keeps the fonts it downloads in memory until it restarts. Every image with new text downloads a small font made for that text, such as a 26 KB file for a Japanese title, so the memory of a long-running Node.js, Bun, or Deno server grows with the number of different titles it renders. On Cloudflare Workers, the downloads also count toward the [subrequest limit](https://developers.cloudflare.com/workers/platform/limits/#subrequests) and add latency to the first render.
+
+To decide which fonts load and when, pass the fonts your images need in `fonts` and set `fallbackFonts: false`. Void then downloads no fonts, and characters that neither your fonts nor Geist can draw are drawn as boxes:
+
+```ts
+return new ImageResponse(postCard(post), {
+ fonts: [{ name: 'Noto Sans JP', data: notoSansJp }],
+ fallbackFonts: false,
+});
+```
+
+A font passed as data loads once per server, however many images use it.
+
+## Add the image to a page
+
+Point an `og:image` tag at the route from your page's [`head()`](./pages-routing/head.md):
+
+```ts
+// pages/posts/[slug].server.ts
+import { defineHead } from 'void';
+
+export interface Props {
+ post: { title: string; slug: string };
+}
+
+export const head = defineHead((c, props) => {
+ return {
+ title: props.post.title,
+ meta: [
+ { property: 'og:image', content: new URL(`/og/${props.post.slug}`, c.req.url).href },
+ { name: 'twitter:card', content: 'summary_large_image' },
+ ],
+ };
+});
+```
+
+## Cache generated images
+
+Each request renders the image again. A simple 1200 × 630 card takes around 10 ms of CPU time, one with gradients, bold text, or emoji 30 to 80 ms, and the first render in a new Worker instance more than 100 ms. [Workers Free allows 10 ms per request](../reference/config.md#worker) and fails requests from a Worker that keeps exceeding it, so on Workers Free, cache image routes with [revalidation](./edge/revalidation.md):
+
+```json
+{
+ "routing": {
+ "revalidate": {
+ "/og/*": 86400
+ }
+ }
+}
+```
+
+Void never caches paths under `/api`, so serve images from another path, such as `/og/...`. The cache ignores query strings, so put values in the path, or list the query parameters in [`routing.revalidateQueryAllowlist`](../reference/config.md#routing-revalidatequeryallowlist).
+
+## Worker size
+
+On Cloudflare, importing `void/og` adds Takumi's WebAssembly renderer to your Worker: about 4.3 MB, or 1.9 MB compressed. A larger Worker takes longer to upload on each deploy and to start in a new instance. Apps that do not import `void/og` do not include the renderer.
+
+## Node.js, Bun, and Deno
+
+On the [Node.js, Bun, and Deno targets](../integrations/nodejs-bun-deno.md), add `takumi-js` to your app's `dependencies`, not `devDependencies`, so production installs include it:
+
+```sh
+npm install takumi-js@~2.20.0
+```
+
+If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one.
+
+The server keeps every font it downloads for text that Geist cannot draw until it restarts, so its memory grows with each image that has new text. If your images show many different titles in other scripts, pass your own fonts with `fallbackFonts: false`; see [Control font downloads](#control-font-downloads).
diff --git a/docs/guide/pages-routing/head.md b/docs/guide/pages-routing/head.md
index d53f44ef2..455b433b3 100644
--- a/docs/guide/pages-routing/head.md
+++ b/docs/guide/pages-routing/head.md
@@ -38,7 +38,7 @@ export const head = defineHead((c, props) => {
});
```
-`head()` runs server-side only, after the loader resolves.
+`head()` runs server-side only, after the loader resolves. To generate the image an `og:image` tag points to, see [Open Graph Images](../og-images.md).
## Site defaults
diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md
index 13952b812..818650eb6 100644
--- a/docs/integrations/nodejs-bun-deno.md
+++ b/docs/integrations/nodejs-bun-deno.md
@@ -103,6 +103,10 @@ Supply server values through your host's environment and read them with `process
For browser code, use Vite's `import.meta.env.VITE_*` values. These are embedded at build time and must not contain secrets.
+### Open Graph images
+
+[`void/og`](../guide/og-images.md) needs `takumi-js` in your app's `dependencies`. See [Node.js, Bun, and Deno](../guide/og-images.md#node-js-bun-and-deno).
+
### No Cloudflare bindings
The following imports are **not available** with a non-CF target and produce a compile-time error:
diff --git a/docs/reference/api.md b/docs/reference/api.md
index b5460f654..29e097bf6 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -49,6 +49,7 @@ Import Void's app-facing APIs from these paths:
| `void/live` | `defineLiveStream`, SSE topic fanout runtime types. Server-only. |
| `void/live/client` | `connectLiveStream`, browser helper for one SSE connection plus POST subscribe/unsubscribe control. |
| `void/response` | `convertReturnValue` |
+| `void/og` | `ImageResponse` and `ImageResponseOptions`. Renders JSX or HTML to a 1200 × 630 PNG `Response`. See [Open Graph Images](../guide/og-images.md). |
| `void/validator` | `runValidation`, `ValidatorSlots`, `HandlerInput` |
| `void/drizzle-zod` | Re-exports [`drizzle-zod`](https://orm.drizzle.team/docs/zod) for schema-derived Zod validators for Drizzle tables |
| `void/drizzle-valibot` | Re-exports [`drizzle-valibot`](https://orm.drizzle.team/docs/valibot) for schema-derived Valibot validators for Drizzle tables |
diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md
index 3842f09dc..8225ded4a 100644
--- a/packages/void/AGENTS.md
+++ b/packages/void/AGENTS.md
@@ -157,6 +157,18 @@ Vite/CLI dependency graph.
`void/remote` stays exported only for semver. Generated code imports
`#self/remote`. Remove the subpath in the next major version.
+`void/og` is a thin `ImageResponse` layer over `takumi-js`. Keep
+`ImageResponseOptions` declared in `og.ts` and map each option to Takumi:
+passing Takumi's options through would let a Takumi upgrade change the API.
+On Node.js, Bun and Deno, Takumi loads its renderer from files next to its
+code, which a bundle cannot carry, so `nodeTargetPlugin` fails a build unless
+the app resolves a `takumi-js` in void's range. takumi-js is on `~2.20`; skip
+2.19.0, which draws a word as boxes when the first font covering a character
+lacks its glyph. Before raising the range, render Japanese next to "Łódź" in
+a fresh process and check that Geist still draws `BUILT_IN` in `og-fonts.ts`,
+then update the range, the install command and the Worker size in
+`docs/guide/og-images.md`.
+
Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default).
### Route Filename Parsing Rules
diff --git a/packages/void/package.json b/packages/void/package.json
index a4d147d78..8fbb26270 100644
--- a/packages/void/package.json
+++ b/packages/void/package.json
@@ -337,6 +337,11 @@
"import": "./dist/runtime/limits.mjs",
"require": "./dist/runtime/limits.mjs"
},
+ "./og": {
+ "types": "./dist/runtime/og.d.mts",
+ "import": "./dist/runtime/og.mjs",
+ "require": "./dist/runtime/og.mjs"
+ },
"./deploy": {
"types": "./dist/deploy.d.mts",
"import": "./dist/deploy.mjs"
@@ -386,6 +391,7 @@
"postal-mime": "catalog:",
"proper-lockfile": "^4.1.2",
"smol-toml": "catalog:",
+ "takumi-js": "~2.20.0",
"terminal-link": "^5.0.0",
"yaml": "^2.9.1"
},
diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md
index edc6e4134..d346ee524 100644
--- a/packages/void/skills/void/SKILL.md
+++ b/packages/void/skills/void/SKILL.md
@@ -51,6 +51,7 @@ Use `void deploy --skip-migrations` when database migrations run separately; it
| Platform AI logging policy | `docs/reference/cli/platform-users.md#ai-gateway-logging` |
| Durable State and WebSockets | `docs/guide/durable-state.md`, `docs/guide/websockets.md` |
| Server-sent events and live streams | `docs/guide/sse.md`, `docs/guide/live.md` |
+| Open Graph and social images | `docs/guide/og-images.md`, `docs/guide/edge/revalidation.md` |
| Sandboxes | `docs/guide/sandboxes.md` |
| Email | `docs/guide/email.md`, then the matching guide under `docs/guide/email/` |
| KV / storage / queues / cron jobs | `docs/guide/kv.md`, `docs/guide/storage.md`, `docs/guide/queues.md`, `docs/guide/jobs.md` |
diff --git a/packages/void/src/installed-packages.ts b/packages/void/src/installed-packages.ts
index bf9f41f96..656ff5c39 100644
--- a/packages/void/src/installed-packages.ts
+++ b/packages/void/src/installed-packages.ts
@@ -5,6 +5,8 @@ import { parseSync } from 'vite';
import { staticModuleReferences } from './shared/module-references.ts';
type PackageManifest = {
+ readonly name?: string;
+ readonly version?: string;
readonly type?: string;
readonly module?: unknown;
readonly main?: unknown;
@@ -15,7 +17,7 @@ type PackageManifest = {
readonly peerDependencies?: Record;
};
-function readManifest(dir: string): PackageManifest | undefined {
+export function readManifest(dir: string): PackageManifest | undefined {
try {
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as PackageManifest;
} catch {
@@ -26,7 +28,7 @@ function readManifest(dir: string): PackageManifest | undefined {
type PnpApi = { resolveToUnqualified(request: string, issuer: string): string | null };
/** The directory Node loads `name` from for code in `fromDir`. Ignores `exports`. */
-function findInstalledPackage(name: string, fromDir: string): string | undefined {
+export function findInstalledPackage(name: string, fromDir: string): string | undefined {
// A Yarn Plug'n'Play install has no node_modules. Under `yarn`, its runtime adds
// `findPnpApi` to `node:module` and serves the packages' files from its archives.
const pnp = (module as { findPnpApi?: (path: string) => PnpApi | null }).findPnpApi?.(fromDir);
diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts
index 70abc4075..734c51738 100644
--- a/packages/void/src/plugin-node-target.ts
+++ b/packages/void/src/plugin-node-target.ts
@@ -1,11 +1,15 @@
-import { existsSync } from 'node:fs';
+import { existsSync, realpathSync } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
-import { pathToFileURL } from 'node:url';
+import { fileURLToPath, pathToFileURL } from 'node:url';
import mime from 'mime';
-import { dirname, isAbsolute, join, relative, resolve } from 'pathslash';
+import { dirname, isAbsolute, join, relative, resolve, toSlash } from 'pathslash';
import { isFileServingAllowed, type Plugin } from 'vite';
-import { findVoidDependentPackages } from './installed-packages.ts';
+import {
+ findInstalledPackage,
+ findVoidDependentPackages,
+ readManifest,
+} from './installed-packages.ts';
import { readProjectPaths } from './project-paths.ts';
/** Imports that require CF bindings and are not available in Node.js mode. */
@@ -322,5 +326,114 @@ export function nodeTargetPlugin(
},
};
- return [guardPlugin, cloudflareModulesPlugin];
+ // void/og renders with takumi-js, which loads its renderer from files next to its own code (a
+ // native addon on Node.js and Bun) that a bundle cannot carry. A build leaves a package that
+ // resolves from the app root to the runtime, so the app must install a version that void/og
+ // supports. With pnpm, void's own copy does not resolve from the root and would be bundled.
+ // The dev server resolves it from void, so there the check only warns.
+ let warned = false;
+ const imageRendererPlugin: Plugin = {
+ name: 'void:node-target-image-renderer',
+ enforce: 'pre',
+ resolveId: {
+ filter: { id: /^takumi-js(?:\/|$)/ },
+ handler(_id, importer) {
+ // An app may also use takumi-js itself, at any version.
+ if (!importer || !realPath(importer).startsWith(`${getVoidPackageDir()}/`)) {
+ return;
+ }
+ const range = readTakumiRange();
+ const installed = findInstalledPackage('takumi-js', this.environment.config.root);
+ const version = installed && readManifest(installed)?.version;
+ if (version && satisfiesRange(version, range)) {
+ return;
+ }
+ const found = version ? ` The app has takumi-js ${version}.` : '';
+ const message =
+ `void/og needs takumi-js@${range} in your app's dependencies with target '${target}', so ` +
+ `the build leaves it to ${RUNTIME_NAMES[target]}.${found} Install it, for example with ` +
+ `\`npm install takumi-js@${range}\`.`;
+ if (this.environment.config.command === 'build') {
+ throw new Error(message);
+ }
+ if (!warned) {
+ warned = true;
+ this.warn(message);
+ }
+ },
+ },
+ };
+
+ return [guardPlugin, cloudflareModulesPlugin, imageRendererPlugin];
+}
+
+const RUNTIME_NAMES: Record = {
+ node: 'Node.js',
+ bun: 'Bun',
+ deno: 'Deno',
+};
+
+let voidPackageDir: string | undefined;
+
+function getVoidPackageDir(): string {
+ return (voidPackageDir ??= findVoidPackageDir(toSlash(fileURLToPath(import.meta.url))));
+}
+
+/**
+ * The `void` package root above `file`. Walks up rather than assuming a depth, so the
+ * lookup holds wherever the bundler places this module in `dist/` (Bun's `node:module`
+ * has no `findPackageJSON`).
+ */
+export function findVoidPackageDir(file: string): string {
+ for (let dir = dirname(file); ; dir = dirname(dir)) {
+ if (readManifest(dir)?.name === 'void') {
+ return dir;
+ }
+ if (dirname(dir) === dir) {
+ throw new Error(`void/og: could not find void's package.json above ${file}.`);
+ }
+ }
+}
+
+/** Vite resolves symlinks in importer paths unless `resolve.preserveSymlinks` is set. */
+function realPath(path: string): string {
+ try {
+ return toSlash(realpathSync(path));
+ } catch {
+ return path;
+ }
+}
+
+/** The takumi-js range void/og is built and tested against, from void's package.json. */
+function readTakumiRange(): string {
+ const range = readManifest(getVoidPackageDir())?.dependencies?.['takumi-js'];
+ if (!range || !/^[~^]\d+\.\d+\.\d+$/.test(range)) {
+ throw new Error(
+ `void/og: expected a ^ or ~ takumi-js range in void's package.json, got ${range}.`,
+ );
+ }
+ return range;
+}
+
+/** Whether `version` is in the `^` or `~` `range` (a major version above 0, as takumi-js uses). */
+function satisfiesRange(version: string, range: string): boolean {
+ const parse = (value: string) => /^[~^]?(\d+)\.(\d+)\.(\d+)$/.exec(value)?.slice(1).map(Number);
+ // A prerelease is outside the range.
+ const actual = parse(version);
+ const minimum = parse(range);
+ // `~` also fixes the minor version.
+ const fixed = range.startsWith('~') ? 2 : 1;
+ if (
+ !actual ||
+ !minimum ||
+ actual.slice(0, fixed).some((part, index) => part !== minimum[index])
+ ) {
+ return false;
+ }
+ for (const part of [1, 2]) {
+ if (actual[part] !== minimum[part]) {
+ return actual[part]! > minimum[part]!;
+ }
+ }
+ return true;
}
diff --git a/packages/void/src/runtime/og-fonts.ts b/packages/void/src/runtime/og-fonts.ts
new file mode 100644
index 000000000..5a164d06e
--- /dev/null
+++ b/packages/void/src/runtime/og-fonts.ts
@@ -0,0 +1,240 @@
+/**
+ * Fonts for `void/og`: the app's own fonts, keyed so each registers with
+ * Takumi once, and Google Fonts fallbacks for text that Takumi's built-in Geist
+ * cannot draw.
+ */
+import type { FontLoader, Node } from 'takumi-js';
+import { collectCodepoints, googleFonts, subsetFonts, type FontSubset } from 'takumi-js/helpers';
+
+/** A font file's URL, or a font's bytes with its name, weight and style. */
+export type Font =
+ | string
+ | {
+ name: string;
+ data: ArrayBuffer | Uint8Array;
+ /**
+ * The weight the font draws, from 1 to 1000. Leave it out for a variable
+ * font, so each element's `fontWeight` sets the weight; with it, the font
+ * draws only this weight.
+ */
+ weight?: number;
+ style?: 'normal' | 'italic';
+ /**
+ * Accepted and ignored: text uses the first font that has the glyph. The
+ * `lang` option, or attribute on the root element, chooses the font for
+ * Chinese characters instead.
+ */
+ lang?: string;
+ };
+
+/** A fallback font file that failed to download. */
+export class FallbackFontError extends Error {}
+
+// Google Fonts families for text the default font cannot draw: each script's
+// Noto family. Takumi has no hook for characters that no font has, so these
+// families are matched by script before the render. `cjkFamily` settles the
+// first one; `Noto Sans` takes any other text.
+const FALLBACK_FONTS: Array<[script: RegExp, family: string]> = [
+ // Script extensions, so marks such as ・ and ー, which Unicode files under the
+ // Common script, stay with the family that draws the kana around them.
+ [/[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana} -〿-]/u, 'Noto Sans JP'],
+ [/\p{sc=Hangul}/u, 'Noto Sans KR'],
+ [/\p{sc=Thai}/u, 'Noto Sans Thai'],
+ [/\p{sc=Arabic}/u, 'Noto Sans Arabic'],
+ [/\p{sc=Hebrew}/u, 'Noto Sans Hebrew'],
+ [/\p{sc=Devanagari}/u, 'Noto Sans Devanagari'],
+ [/\p{sc=Bengali}/u, 'Noto Sans Bengali'],
+ [/\p{sc=Tamil}/u, 'Noto Sans Tamil'],
+ [/\p{sc=Telugu}/u, 'Noto Sans Telugu'],
+ [/\p{sc=Kannada}/u, 'Noto Sans Kannada'],
+ [/\p{sc=Malayalam}/u, 'Noto Sans Malayalam'],
+ // The default font's own subsets, so a word stays in one typeface.
+ [/[\p{sc=Cyrillic}µĀ-ɏḀ-ỿ]/u, 'Geist'],
+ [/\p{Sm}/u, 'Noto Sans Math'],
+ [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols'],
+ [/[\p{So}\p{No}\p{Nl}]/u, 'Noto Sans Symbols 2'],
+];
+
+// The fallback families Google Fonts serves only at weight 400. Every other
+// family above is a variable font with a 100–900 weight axis, and is asked
+// for as that range: Google then serves `font-weight: 100 900`, Takumi leaves
+// the face without a fixed weight, and `font-weight: 700` drives the axis.
+// Asked for at a single 400, a variable face is pinned to 400 and bold text is
+// only thickened, so 700 and 900 look the same. Asking for a range from these
+// two fails the whole request with HTTP 400, which would lose the family.
+// Found by asking css2 for each family with `wght@100..900`.
+const SINGLE_WEIGHT_FAMILIES = new Set(['Noto Sans Math', 'Noto Sans Symbols 2']);
+
+// Google Fonts answers a `text` parameter with one file holding just those
+// characters, with the whole weight axis. Without it, a family comes as files
+// split by character range: a Japanese title needs about 20 of Noto Sans JP's
+// 124, 480 KB in all, against one 26 KB file. Past a query of about 7,500
+// characters (some 800 kanji), Google returns every range file instead, and
+// longer queries fail, so longer text asks for the range files.
+const GOOGLE_FONTS_CSS = 'https://fonts.googleapis.com/css2';
+const MAX_TEXT_QUERY = 6000;
+
+function cssUrl(characters: Array): string | undefined {
+ const text = encodeURIComponent([...new Set(characters)].sort().join(''));
+ return text.length <= MAX_TEXT_QUERY ? `${GOOGLE_FONTS_CSS}?text=${text}` : undefined;
+}
+
+// What Takumi's built-in Geist draws beyond ASCII and Latin-1 without µ, found
+// by rendering each code point with takumi-js 2.18 (2.20 draws the same): some
+// accented letters and combining marks, spaces, format characters and variation
+// selectors, quotes, dashes, € ™ − ↑ ↓. Number forms such as ⅓ stay missing:
+// no served subset has them.
+/* oxlint-disable no-misleading-character-class -- the marks and selectors are listed on their own */
+const BUILT_IN =
+ /[\u{100}\u{101}\u{112}\u{113}\u{12A}\u{12B}\u{131}\u{14C}\u{14D}\u{152}\u{153}\u{16A}\u{16B}\u{178}\u{1D5}\u{1D6}\u{1DE}\u{1DF}\u{1E2}\u{1E3}\u{22A}-\u{22D}\u{232}\u{233}\u{2BC}\u{2C6}\u{2DA}\u{2DC}\u{304}\u{308}\u{34F}\u{37E}\u{387}\u{1E20}\u{1E21}\u{1E26}\u{1E27}\u{1E4E}\u{1E4F}\u{1E7A}\u{1E7B}\u{1E84}\u{1E85}\u{1E8C}\u{1E8D}\u{1E97}\u{2000}-\u{200F}\u{2013}\u{2014}\u{2018}-\u{201A}\u{201C}-\u{201E}\u{2020}-\u{2022}\u{2026}\u{2028}-\u{2030}\u{2032}\u{2033}\u{2039}\u{203A}\u{2044}\u{205F}-\u{206F}\u{20AC}\u{2122}\u{212A}\u{212B}\u{2191}\u{2193}\u{2212}\u{FE00}-\u{FE0F}\u{FEFF}]/u;
+/* oxlint-enable no-misleading-character-class */
+
+function hasBytes(font: Font): font is Exclude {
+ return (
+ typeof font === 'object' &&
+ (font.data instanceof ArrayBuffer || font.data instanceof Uint8Array)
+ );
+}
+
+const fontHashes = new WeakMap>();
+
+// Takumi keeps every registered font for the life of the process and tells
+// them apart by buffer identity, so a font read anew for each request would be
+// registered again every time. Key it by content.
+export async function keyedFont(font: Font): Promise {
+ if (!hasBytes(font)) {
+ return font;
+ }
+ const { lang: _lang, data, ...details } = font;
+ let hash = fontHashes.get(data);
+ if (!hash) {
+ hash = crypto.subtle
+ .digest('SHA-256', data instanceof ArrayBuffer ? data : new Uint8Array(data))
+ .then((digest) =>
+ Array.from(new Uint8Array(digest, 0, 8), (byte) => byte.toString(16).padStart(2, '0')).join(
+ '',
+ ),
+ );
+ fontHashes.set(data, hash);
+ }
+ return {
+ ...details,
+ key: `void/og ${details.name ?? ''}:${details.weight ?? ''}:${details.style ?? ''}:${await hash}`,
+ data: () => data,
+ };
+}
+
+// Han text is drawn as Japanese unless a language says otherwise. Takumi takes
+// the first font with a glyph and ignores `lang`, so one family draws all Han
+// text in an image, chosen from the `lang` option or the root element's `lang`.
+function cjkFamily(text: string, lang = ''): string {
+ // An explicit language wins over the scripts in the text: a Traditional
+ // Chinese title with a katakana loanword (タワー) is still Chinese. Google's
+ // Noto Sans SC, TC and KR all include kana, so no character is left without
+ // a glyph.
+ const tag = lang.toLowerCase();
+ if (tag.startsWith('ja')) {
+ return 'Noto Sans JP';
+ }
+ if (tag.startsWith('ko')) {
+ return 'Noto Sans KR';
+ }
+ if (tag.startsWith('zh')) {
+ return /hant|-tw|-hk|-mo/.test(tag) ? 'Noto Sans TC' : 'Noto Sans SC';
+ }
+ // Without one, kana marks Japanese and Hangul marks Korean.
+ if (/[\p{sc=Hiragana}\p{sc=Katakana}]/u.test(text)) {
+ return 'Noto Sans JP';
+ }
+ if (/\p{sc=Hangul}/u.test(text)) {
+ return 'Noto Sans KR';
+ }
+ return 'Noto Sans JP';
+}
+
+/**
+ * The Google Fonts subsets that draw the characters in `node` that the
+ * built-in Geist cannot. A file that fails to download adds its key to
+ * `failed` and rejects with a `FallbackFontError`.
+ */
+export async function fallbackFonts(
+ node: Node,
+ lang: string | undefined,
+ timeout: number | undefined,
+ failed: Set,
+): Promise> {
+ const missing = Array.from(collectCodepoints(node))
+ .filter((codepoint) => codepoint === 0xb5 || codepoint > 0xff)
+ .map((codepoint) => String.fromCodePoint(codepoint))
+ .filter((character) => !BUILT_IN.test(character));
+ // The characters each family is asked to draw.
+ const families = new Map>();
+ for (const [script, name] of FALLBACK_FONTS) {
+ const characters = missing.filter((character) => script.test(character));
+ if (characters.length > 0) {
+ // Han text can go to the same family as Hangul, so add to it.
+ const family = name === 'Noto Sans JP' ? cjkFamily(missing.join(''), lang) : name;
+ families.set(family, [...(families.get(family) ?? []), ...characters]);
+ }
+ }
+ const unmatched = missing.filter(
+ (character) => !FALLBACK_FONTS.some(([script]) => script.test(character)),
+ );
+ if (unmatched.length > 0) {
+ families.set('Noto Sans', unmatched);
+ }
+ if (families.size === 0) {
+ return [];
+ }
+ // One request per family: takumi caches the CSS by URL.
+ const subsets = await Promise.all(
+ [...families].map(async ([family, characters]) => {
+ try {
+ return await googleFonts({
+ families: [
+ SINGLE_WEIGHT_FAMILIES.has(family) ? family : { name: family, weight: '100..900' },
+ ],
+ baseUrl: cssUrl(characters),
+ timeout,
+ });
+ } catch (error) {
+ console.warn(`void/og: could not load ${family} from Google Fonts.`, error);
+ return [];
+ }
+ }),
+ );
+ // Latin text should keep the default font, but Takumi draws each character
+ // with the first font that has it and puts registered fonts before its
+ // built-in Geist, so take ASCII and Latin-1 but µ out of every subset. A
+ // subset without ranges is a whole font.
+ const stripped = subsets.flat().flatMap((subset) => {
+ const unicodeRange = (
+ subset.unicodeRange.length > 0 ? subset.unicodeRange : [[0x100, 0x10ffff] as const]
+ ).flatMap(([start, end]): Array<[number, number]> => [
+ ...(start <= 0xb5 && 0xb5 <= end ? [[0xb5, 0xb5] as [number, number]] : []),
+ ...(end > 0xff ? [[Math.max(start, 0x100), end] as [number, number]] : []),
+ ]);
+ if (unicodeRange.length === 0) {
+ return [];
+ }
+ return {
+ ...subset,
+ unicodeRange,
+ // Private names: Takumi keeps fonts per process, so the app's own copy
+ // of the family, Latin glyphs included, must not join this one.
+ name: `void/og ${subset.name}`,
+ key: `void/og ${subset.key}`,
+ data: () =>
+ subset.data().catch((cause: unknown) => {
+ // Record and report each failed file here: when several fail at
+ // once, Takumi's rejection carries only the first.
+ failed.add(`void/og ${subset.key}`);
+ console.warn(`void/og: could not download ${subset.name}. Rendering without it.`, cause);
+ throw new FallbackFontError(`void/og: could not download ${subset.name}.`, { cause });
+ }),
+ };
+ });
+ // Takumi downloads the subsets of its list-marker sample as well, so keep
+ // only those the text uses.
+ return subsetFonts({ fonts: stripped, source: node });
+}
diff --git a/packages/void/src/runtime/og.ts b/packages/void/src/runtime/og.ts
new file mode 100644
index 000000000..008d33762
--- /dev/null
+++ b/packages/void/src/runtime/og.ts
@@ -0,0 +1,192 @@
+/**
+ * `void/og` — an `ImageResponse` that renders JSX or HTML to an image with
+ * Takumi.
+ *
+ * Takumi does the rendering (Wasm on workerd, its native addon on Node). This
+ * module adds what a route handler needs around it: a 1200×630 default size,
+ * the response headers, `debug`, and Google Fonts fallbacks for text that
+ * Takumi's built-in Geist cannot draw.
+ */
+import { render, type FontLoader, type Node, type RenderInput } from 'takumi-js';
+import type { ImageFetchCache } from 'takumi-js/helpers';
+import { extractEmojis } from 'takumi-js/helpers/emoji';
+import { fromHtml } from 'takumi-js/helpers/html';
+import { fromJsx } from 'takumi-js/helpers/jsx';
+import { FallbackFontError, fallbackFonts, keyedFont, type Font } from './og-fonts.ts';
+
+/**
+ * {@link renderImage} function options.
+ */
+export interface RenderImageOptions {
+ /** Image width in pixels. Defaults to 1200. */
+ width?: number;
+ /** Image height in pixels. Defaults to 630. */
+ height?: number;
+ /** Draws a border around every box. */
+ debug?: boolean;
+ /** Fonts to draw text with, before the built-in Geist. */
+ fonts?: Array;
+ /**
+ * Set to false to download no fonts from Google Fonts for characters that
+ * the built-in Geist cannot draw, leaving them to `fonts`. Defaults to true.
+ */
+ fallbackFonts?: boolean;
+ /** The emoji images to draw. Defaults to `twemoji`. */
+ emoji?: 'twemoji' | 'blobmoji' | 'noto' | 'openmoji' | 'fluent' | 'fluentFlat';
+ /**
+ * The language of the text, such as `ja` or `zh-TW`, which chooses the style
+ * of Chinese characters. Defaults to the root element's `lang` attribute.
+ */
+ lang?: string;
+ /** Limits for downloading the images and emoji in the markup. */
+ images?: {
+ /** Returns false for a URL the image must not download. */
+ allowUrl?: (url: string) => boolean;
+ /** The largest image to download, in bytes. */
+ maxBytes?: number;
+ /** Milliseconds to wait for each download. */
+ timeout?: number;
+ /** Set to false to leave out an image or emoji that fails to download. */
+ throwOnError?: boolean;
+ };
+}
+/**
+ * Void declares these options rather than passing Takumi's render options
+ * through, so a Takumi upgrade does not change this API.
+ */
+export interface ImageResponseOptions extends ResponseInit, RenderImageOptions {}
+
+// Emoji come from jsDelivr by code point and never change, so keep them for the
+// life of the isolate; other images are fetched for each render.
+const EMOJI_ORIGIN = 'https://cdn.jsdelivr.net/';
+const emojiCache = new Map>();
+
+/**
+ * A `Response` whose body is the image rendered from `element`: React JSX, an
+ * HTML string, or a Takumi node tree. It renders a 1200×630 PNG by default,
+ * which clients revalidate on each use.
+ */
+export class ImageResponse extends Response {
+ constructor(element: RenderInput, options: ImageResponseOptions = {}) {
+ const { status, statusText, headers, ...imageOptions } = options;
+ // Set these two headers unless the caller's `headers` has them. A failed
+ // render still ends as a complete response, so clients revalidate the
+ // image rather than cache it. With no validator, revalidating fetches it
+ // again, so the dev server needs no header of its own.
+ const responseHeaders = new Headers(headers);
+ if (!responseHeaders.has('content-type')) {
+ responseHeaders.set('content-type', 'image/png');
+ }
+ if (!responseHeaders.has('cache-control')) {
+ responseHeaders.set('cache-control', 'public, max-age=0, must-revalidate');
+ }
+ // Render while the body streams, so a failure errors the body.
+ const body = new ReadableStream({
+ async start(controller) {
+ try {
+ const image = await renderImage(element, imageOptions);
+ controller.enqueue(image);
+ controller.close();
+ } catch (error) {
+ console.error('void/og: failed to render the image.', error);
+ controller.error(error);
+ }
+ },
+ });
+ super(body, { status, statusText, headers: responseHeaders });
+ }
+}
+
+async function renderImage(element: RenderInput, options: RenderImageOptions): Promise {
+ // Convert once, here: the fallback fonts depend on the final text, and
+ // function components must not run twice. Emoji become images first, as in
+ // the render, so their code points ask for no symbol font.
+ const { node: tree, css } = await toNode(element);
+ const emoji = options.emoji ?? 'twemoji';
+ const node = extractEmojis(tree, emoji);
+ // Only the documented limits reach Takumi, which takes more image options.
+ const { allowUrl, maxBytes, timeout, throwOnError } = options.images ?? {};
+ // One cache for both attempts below, so a retry fetches no image again.
+ const fetchCache = imageCache();
+ const renderNode = (fonts: Array) =>
+ render(node, {
+ width: options.width ?? 1200,
+ height: options.height ?? 630,
+ drawDebugBorder: options.debug,
+ lang: options.lang,
+ emoji,
+ css,
+ images: { fetchCache, allowUrl, maxBytes, timeout, throwOnError },
+ // Takumi uses every font registered in the process when it gets no list,
+ // which includes earlier images' fallback fonts:
+ // https://github.com/kane50613/takumi/blob/takumi@2.20.0/takumi-helpers/src/renderer.ts#L384-L412
+ fonts,
+ });
+ // Keys of the fallback files that failed to download during this image.
+ const failed = new Set();
+ const [fonts, fallback] = await Promise.all([
+ Promise.all((options.fonts ?? []).map(keyedFont)),
+ options.fallbackFonts === false
+ ? []
+ : fallbackFonts(node, options.lang ?? node.lang, timeout, failed),
+ ]);
+ if (fallback.length === 0) {
+ return renderNode(fonts);
+ }
+ // A font that fails to download leaves its glyphs missing rather than
+ // failing the image.
+ // Takumi downloads the files together and rejects the render on the first
+ // failure, so retry without the files that failed, and only those: a flaky
+ // symbol font must not also drop the Japanese one. Files that did download
+ // stay registered in Takumi, so a retry fetches none of them again.
+ let remaining = fallback;
+ for (;;) {
+ try {
+ return await renderNode([...fonts, ...remaining]);
+ } catch (error) {
+ if (!(error instanceof FallbackFontError)) {
+ throw error;
+ }
+ const rest = remaining.filter((subset) => !failed.has(subset.key));
+ // Each retry must drop a file, so the loop ends. A failure this image
+ // did not record would retry forever; drop every fallback font instead.
+ remaining = rest.length < remaining.length ? rest : [];
+ }
+ }
+}
+
+function toNode(element: RenderInput) {
+ if (typeof element === 'string') {
+ return fromHtml(element);
+ }
+ if (isTakumiNode(element)) {
+ return { node: element, css: [] };
+ }
+ return fromJsx(element);
+}
+
+function isTakumiNode(element: unknown): element is Node {
+ const type = (element as { type?: unknown } | null)?.type;
+ return type === 'container' || type === 'text' || type === 'image';
+}
+
+/** Images fetched once per render, and emoji once per isolate. */
+function imageCache(): ImageFetchCache {
+ const cache = new Map>();
+ return {
+ get: (url) => cache.get(url) ?? emojiCache.get(url),
+ set(url, data) {
+ cache.set(url, data);
+ if (url.startsWith(EMOJI_ORIGIN)) {
+ if (emojiCache.size >= 256) {
+ emojiCache.delete(emojiCache.keys().next().value!);
+ }
+ emojiCache.set(url, data);
+ }
+ },
+ delete(url) {
+ cache.delete(url);
+ emojiCache.delete(url);
+ },
+ };
+}
diff --git a/packages/void/test/integration/og-image.test.ts b/packages/void/test/integration/og-image.test.ts
new file mode 100644
index 000000000..c21ced4b9
--- /dev/null
+++ b/packages/void/test/integration/og-image.test.ts
@@ -0,0 +1,251 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs';
+import { dirname, join } from 'node:path';
+import spawn from 'cross-spawn';
+import { Miniflare, convertV4MiniflareOptions } from 'miniflare';
+import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
+import { killChild, startServer, waitForServer } from '../../../../test/integration-helpers.ts';
+import { collectPrerenderWorkerModules } from '../../src/prerender.ts';
+
+const repository = join(import.meta.dirname, '..', '..', '..', '..');
+const CACHE_HEADER = 'public, max-age=0, must-revalidate';
+// Vitest sets NODE_ENV=test, which would make Vite build in development mode.
+const { NODE_ENV: _nodeEnv, ...userEnv } = process.env;
+
+const OG_ROUTE = `import { ImageResponse } from 'void/og';
+export const GET = () => new ImageResponse('
Hello
');
+`;
+// A .tsx route, as the guide shows.
+const CARD_ROUTE = `import { ImageResponse } from 'void/og';
+function Card({ title }: { title: string }) {
+ return