Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/scripts/packed-consumer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ async function run(command, args, cwd, env = environment) {
}
}

const packedImageRoute = `import { ImageResponse } from 'void/og';
export const GET = () => new ImageResponse('<div style="display:flex;width:100%;height:100%;background:#000;color:#fff">Packed</div>');
`;

/** 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()) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -405,6 +424,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());
Expand Down Expand Up @@ -447,9 +467,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(
Expand All @@ -458,6 +480,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 <h1>Form</h1>;\n}\n',
Expand Down Expand Up @@ -491,12 +515,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+' : '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+' : '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) });
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export default extendConfig(
},
{ text: 'Custom SSR', link: '/guide/ssr' },
{ text: 'SSG', link: '/guide/ssg' },
{ text: 'Open Graph Images', link: '/guide/og-images' },
],
},
{
Expand Down
187 changes: 187 additions & 0 deletions docs/guide/og-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
---
outline: deep
---

# Open Graph Images

Use `ImageResponse` from `void/og` to generate Open Graph and social preview images in a route handler. It takes the same arguments as `ImageResponse` from `next/og` and 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(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
padding: 80,
background: '#0f172a',
color: 'white',
}}
>
<div style={{ fontSize: 72, fontWeight: 700 }}>{post.title}</div>
<div style={{ fontSize: 36, opacity: 0.7 }}>{post.author}</div>
</div>,
);
});
```

`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 `<div tw="flex h-full w-full items-center justify-center bg-white">`.

### 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(
`<div style="display: flex; width: 100%; height: 100%; padding: 80px; font-size: 72px">
${escapeHtml(post.title)}
</div>`,
);
});
```

## 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). |
| `emoji` | `'twemoji'` | Emoji style: `twemoji`, `blobmoji`, `noto`, `openmoji`, `fluent`, `fluentFlat`, or `from-font` to draw emoji with the fonts. |
| `lang` | | Language of the text, such as `'ja'` or `'zh-TW'`, for Chinese characters. |
| `debug` | `false` | Draws a border around every box. |
| `format` | `'png'` | Image format: `png`, `jpeg`, `webp`, or `ico`. |
| `images` | | Limits for remote images: `allowUrl`, `maxBytes`, and `timeout`. Set them when the markup includes request values. |
| `status`, `statusText`, `headers` | | The response status and headers. |

Takumi's other render options, such as `devicePixelRatio`, are passed through. See [Takumi's reference](https://takumi.kane.tw/docs/reference).

Responses have these headers unless you set them in `headers`:

```http
Content-Type: image/png
Cache-Control: public, max-age=0, must-revalidate
```

During local development, `Cache-Control` is `no-cache, no-store`. 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`. 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 the matching font from Google Fonts while rendering, whether or not `fonts` covers them. If the download fails, those characters are drawn as boxes.

Chinese characters are drawn in the Japanese style, as in `next/og`, unless the text has Korean around them, or the `lang` option or the root element's `lang` attribute names the language:

```ts
return new ImageResponse(`<div lang="zh-TW" style="display: flex">${title}</div>`);
```

To use your own fonts, pass `fonts`. An entry is either a font URL or an object with the font's data, as in `next/og`:

```ts
return new ImageResponse(postCard(post), {
fonts: [
'https://example.com/fonts/Inter-Bold.woff2',
{ name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
],
});
```

Fonts can be TTF, OTF, or WOFF2 files; on Node.js and Bun, WOFF too.

## 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<Props>((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).

## Coming from `next/og`

Change the import:

```ts
import { ImageResponse } from 'next/og'; // [!code --]
import { ImageResponse } from 'void/og'; // [!code ++]
```

Then:

- Move `app/api/og/route.tsx` to a route file outside `/api`, such as `routes/og.tsx`, so its images can be [cached](#cache-generated-images).
- Read the request from Hono's context: `c.req.url`, `c.req.param()`, and `c.req.query()`.
- Void draws images with Takumi rather than Satori, so layout can differ slightly. See [Takumi vs. Satori](https://takumi.kane.tw/docs/comparison-to-satori).
- A font's `lang` is accepted but does not choose the font for a language. Use the `lang` option or attribute for Chinese characters instead.

## 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.18.2
```

If the build cannot find a version of `takumi-js` that Void supports, it fails and prints the command to install one.
2 changes: 1 addition & 1 deletion docs/guide/pages-routing/head.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const head = defineHead<Props>((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

Expand Down
4 changes: 4 additions & 0 deletions docs/integrations/nodejs-bun-deno.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,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:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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`, with the same arguments as `next/og`. 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 |
Expand Down
18 changes: 18 additions & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,24 @@ 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 `next/og`-compatible layer over `takumi-js`; its source
cites the @vercel/og, Satori and Next.js code each behavior follows. In workerd,
Takumi resolves its Wasm backend, whose `.wasm` import the Cloudflare plugin
loads as a compiled module in dev and builds. On Node.js, Bun and Deno, Takumi
loads its renderer from files next to its code (a native addon on Node.js and
Bun), which a bundle cannot carry, so `nodeTargetPlugin` fails a build unless
the app root resolves a `takumi-js` in the range void declares. Takumi's JSX
helper imports `react` as an optional peer; Vite stubs it in apps without
React, and Takumi catches the failed import. Fallback fonts come from Google
Fonts by the scripts in the text, under private family names, with Latin-1
taken out so Latin text keeps Takumi's built-in Geist. takumi-js stays on
`~2.18`: 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
(Noto Sans JP's latin-ext subset has no Ł). Reordering the families or dropping
`unicode-range` does not avoid it. Check a fix in a fresh process, as 2.19.0
falls back to faces an earlier render loaded, then raise the range and the
install command in `docs/guide/og-images.md`.

Build output uses `.mjs` / `.d.mts` extensions (`vp pack` / tsdown default).

### Route Filename Parsing Rules
Expand Down
Loading